Commit 9b574878 by andrewlewis Committed by Oliver Woodman

Handle gapless audio metadata in elst.

Only edit lists that truncate the first/last sample are supported, which is
sufficient to handle AAC encoder delay/padding.
-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=121011278
parent 23cb9532
......@@ -15,74 +15,91 @@
*/
package com.google.android.exoplayer.extractor;
import com.google.android.exoplayer.Format;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility for parsing and representing gapless playback information.
* Holder for gapless playback information
*/
public final class GaplessInfo {
public final class GaplessInfoHolder {
private static final String GAPLESS_COMMENT_ID = "iTunSMPB";
private static final Pattern GAPLESS_COMMENT_PATTERN =
Pattern.compile("^ [0-9a-fA-F]{8} ([0-9a-fA-F]{8}) ([0-9a-fA-F]{8})");
/**
* Parses a gapless playback comment (stored in an ID3 header or MPEG 4 user data).
* The number of samples to trim from the start of the decoded audio stream, or
* {@link Format#NO_VALUE} if not set.
*/
public int encoderDelay;
/**
* The number of samples to trim from the end of the decoded audio stream, or
* {@link Format#NO_VALUE} if not set.
*/
public int encoderPadding;
/**
* Creates a new holder for gapless playback information.
*/
public GaplessInfoHolder() {
encoderDelay = Format.NO_VALUE;
encoderPadding = Format.NO_VALUE;
}
/**
* Populates the holder with data from an MP3 Xing header, if valid and non-zero.
*
* @param value The 24-bit value to parse.
* @return Whether the holder was populated.
*/
public boolean setFromXingHeaderValue(int value) {
int encoderDelay = value >> 12;
int encoderPadding = value & 0x0FFF;
if (encoderDelay > 0 || encoderPadding > 0) {
this.encoderDelay = encoderDelay;
this.encoderPadding = encoderPadding;
return true;
}
return false;
}
/**
* Populates the holder with data parsed from a gapless playback comment (stored in an ID3 header
* or MPEG 4 user data), if valid and non-zero.
*
* @param name The comment's identifier.
* @param data The comment's payload data.
* @return Parsed gapless playback information, if present and non-zero. {@code null} otherwise.
* @return Whether the holder was populated.
*/
public static GaplessInfo createFromComment(String name, String data) {
public boolean setFromComment(String name, String data) {
if (!GAPLESS_COMMENT_ID.equals(name)) {
return null;
return false;
}
Matcher matcher = GAPLESS_COMMENT_PATTERN.matcher(data);
if (matcher.find()) {
try {
int encoderDelay = Integer.parseInt(matcher.group(1), 16);
int encoderPadding = Integer.parseInt(matcher.group(2), 16);
return encoderDelay == 0 && encoderPadding == 0 ? null
: new GaplessInfo(encoderDelay, encoderPadding);
if (encoderDelay > 0 || encoderPadding > 0) {
this.encoderDelay = encoderDelay;
this.encoderPadding = encoderPadding;
return true;
}
} catch (NumberFormatException e) {
// Ignore incorrectly formatted comments.
}
}
return null;
return false;
}
/**
* Parses gapless playback information associated with an MP3 Xing header.
*
* @param value The 24-bit value to parse.
* @return Parsed gapless playback information, if non-zero. {@code null} otherwise.
*/
public static GaplessInfo createFromXingHeaderValue(int value) {
int encoderDelay = value >> 12;
int encoderPadding = value & 0x0FFF;
return encoderDelay == 0 && encoderPadding == 0 ? null
: new GaplessInfo(encoderDelay, encoderPadding);
}
/**
* The number of samples to trim from the start of the decoded audio stream.
*/
public final int encoderDelay;
/**
* The number of samples to trim from the end of the decoded audio stream.
*/
public final int encoderPadding;
/**
* Creates a new {@link GaplessInfo} with the specified encoder delay and padding.
*
* @param encoderDelay The encoder delay.
* @param encoderPadding The encoder padding.
* Returns whether {@link #encoderDelay} and {@link #encoderPadding} have been set.
*/
private GaplessInfo(int encoderDelay, int encoderPadding) {
this.encoderDelay = encoderDelay;
this.encoderPadding = encoderPadding;
public boolean hasGaplessInfo() {
return encoderDelay != Format.NO_VALUE && encoderPadding != Format.NO_VALUE;
}
}
......@@ -16,7 +16,7 @@
package com.google.android.exoplayer.extractor.mp3;
import com.google.android.exoplayer.extractor.ExtractorInput;
import com.google.android.exoplayer.extractor.GaplessInfo;
import com.google.android.exoplayer.extractor.GaplessInfoHolder;
import com.google.android.exoplayer.util.ParsableByteArray;
import com.google.android.exoplayer.util.Util;
......@@ -43,15 +43,14 @@ import java.nio.charset.Charset;
* Peeks data from the input and parses ID3 metadata.
*
* @param input The {@link ExtractorInput} from which data should be peeked.
* @return The gapless playback information, if present and non-zero. {@code null} otherwise.
* @param out The {@link GaplessInfoHolder} to populate.
* @throws IOException If an error occurred peeking from the input.
* @throws InterruptedException If the thread was interrupted.
*/
public static GaplessInfo parseId3(ExtractorInput input)
public static void parseId3(ExtractorInput input, GaplessInfoHolder out)
throws IOException, InterruptedException {
ParsableByteArray scratch = new ParsableByteArray(10);
int peekedId3Bytes = 0;
GaplessInfo metadata = null;
while (true) {
input.peekFully(scratch.data, 0, 10);
scratch.setPosition(0);
......@@ -63,10 +62,10 @@ import java.nio.charset.Charset;
int minorVersion = scratch.readUnsignedByte();
int flags = scratch.readUnsignedByte();
int length = scratch.readSynchSafeInt();
if (metadata == null && canParseMetadata(majorVersion, minorVersion, flags, length)) {
if (!out.hasGaplessInfo() && canParseMetadata(majorVersion, minorVersion, flags, length)) {
byte[] frame = new byte[length];
input.peekFully(frame, 0, length);
metadata = parseGaplessInfo(new ParsableByteArray(frame), majorVersion, flags);
parseGaplessInfo(new ParsableByteArray(frame), majorVersion, flags, out);
} else {
input.advancePeekPosition(length);
}
......@@ -75,7 +74,6 @@ import java.nio.charset.Charset;
}
input.resetPeekPosition();
input.advancePeekPosition(peekedId3Bytes);
return metadata;
}
private static boolean canParseMetadata(int majorVersion, int minorVersion, int flags,
......@@ -87,18 +85,19 @@ import java.nio.charset.Charset;
&& !(majorVersion == 4 && (flags & 0x0F) != 0);
}
private static GaplessInfo parseGaplessInfo(ParsableByteArray frame, int version, int flags) {
private static void parseGaplessInfo(ParsableByteArray frame, int version, int flags,
GaplessInfoHolder out) {
unescape(frame, version, flags);
// Skip any extended header.
frame.setPosition(0);
if (version == 3 && (flags & 0x40) != 0) {
if (frame.bytesLeft() < 4) {
return null;
return;
}
int extendedHeaderSize = frame.readUnsignedIntToInt();
if (extendedHeaderSize > frame.bytesLeft()) {
return null;
return;
}
int paddingSize;
if (extendedHeaderSize >= 6) {
......@@ -107,17 +106,17 @@ import java.nio.charset.Charset;
frame.setPosition(4);
frame.setLimit(frame.limit() - paddingSize);
if (frame.bytesLeft() < extendedHeaderSize) {
return null;
return;
}
}
frame.skipBytes(extendedHeaderSize);
} else if (version == 4 && (flags & 0x40) != 0) {
if (frame.bytesLeft() < 4) {
return null;
return;
}
int extendedHeaderSize = frame.readSynchSafeInt();
if (extendedHeaderSize < 6 || extendedHeaderSize > frame.bytesLeft() + 4) {
return null;
return;
}
frame.setPosition(extendedHeaderSize);
}
......@@ -126,14 +125,11 @@ import java.nio.charset.Charset;
Pair<String, String> comment;
while ((comment = findNextComment(version, frame)) != null) {
if (comment.first.length() > 3) {
GaplessInfo gaplessInfo =
GaplessInfo.createFromComment(comment.first.substring(3), comment.second);
if (gaplessInfo != null) {
return gaplessInfo;
if (out.setFromComment(comment.first.substring(3), comment.second)) {
break;
}
}
}
return null;
}
private static Pair<String, String> findNextComment(int majorVersion, ParsableByteArray data) {
......
......@@ -21,7 +21,7 @@ import com.google.android.exoplayer.ParserException;
import com.google.android.exoplayer.extractor.Extractor;
import com.google.android.exoplayer.extractor.ExtractorInput;
import com.google.android.exoplayer.extractor.ExtractorOutput;
import com.google.android.exoplayer.extractor.GaplessInfo;
import com.google.android.exoplayer.extractor.GaplessInfoHolder;
import com.google.android.exoplayer.extractor.PositionHolder;
import com.google.android.exoplayer.extractor.SeekMap;
import com.google.android.exoplayer.extractor.TrackOutput;
......@@ -57,6 +57,7 @@ public final class Mp3Extractor implements Extractor {
private final long forcedFirstSampleTimestampUs;
private final ParsableByteArray scratch;
private final MpegAudioHeader synchronizedHeader;
private final GaplessInfoHolder gaplessInfoHolder;
// Extractor outputs.
private ExtractorOutput extractorOutput;
......@@ -64,7 +65,6 @@ public final class Mp3Extractor implements Extractor {
private int synchronizedHeaderData;
private GaplessInfo gaplessInfo;
private Seeker seeker;
private long basisTimeUs;
private long samplesRead;
......@@ -87,6 +87,7 @@ public final class Mp3Extractor implements Extractor {
this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs;
scratch = new ParsableByteArray(4);
synchronizedHeader = new MpegAudioHeader();
gaplessInfoHolder = new GaplessInfoHolder();
basisTimeUs = -1;
}
......@@ -124,11 +125,10 @@ public final class Mp3Extractor implements Extractor {
if (seeker == null) {
setupSeeker(input);
extractorOutput.seekMap(seeker);
int encoderDelay = gaplessInfo != null ? gaplessInfo.encoderDelay : Format.NO_VALUE;
int encoderPadding = gaplessInfo != null ? gaplessInfo.encoderPadding : Format.NO_VALUE;
trackOutput.format(Format.createAudioSampleFormat(null, synchronizedHeader.mimeType,
Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, synchronizedHeader.channels,
synchronizedHeader.sampleRate, encoderDelay, encoderPadding, null, null));
synchronizedHeader.sampleRate, gaplessInfoHolder.encoderDelay,
gaplessInfoHolder.encoderPadding, null, null));
}
return readSample(input);
}
......@@ -208,7 +208,7 @@ public final class Mp3Extractor implements Extractor {
int peekedId3Bytes = 0;
input.resetPeekPosition();
if (input.getPosition() == 0) {
gaplessInfo = Id3Util.parseId3(input);
Id3Util.parseId3(input, gaplessInfoHolder);
peekedId3Bytes = (int) input.getPeekPosition();
if (!sniffing) {
input.skipFully(peekedId3Bytes);
......@@ -289,13 +289,13 @@ public final class Mp3Extractor implements Extractor {
int headerData = frame.readInt();
if (headerData == XING_HEADER || headerData == INFO_HEADER) {
seeker = XingSeeker.create(synchronizedHeader, frame, position, length);
if (seeker != null && gaplessInfo == null) {
if (seeker != null && !gaplessInfoHolder.hasGaplessInfo()) {
// If there is a Xing header, read gapless playback metadata at a fixed offset.
input.resetPeekPosition();
input.advancePeekPosition(xingBase + 141);
input.peekFully(scratch.data, 0, 3);
scratch.setPosition(0);
gaplessInfo = GaplessInfo.createFromXingHeaderValue(scratch.readUnsignedInt24());
gaplessInfoHolder.setFromXingHeaderValue(scratch.readUnsignedInt24());
}
input.skipFully(synchronizedHeader.frameSize);
} else {
......
......@@ -18,7 +18,7 @@ package com.google.android.exoplayer.extractor.mp4;
import com.google.android.exoplayer.C;
import com.google.android.exoplayer.Format;
import com.google.android.exoplayer.ParserException;
import com.google.android.exoplayer.extractor.GaplessInfo;
import com.google.android.exoplayer.extractor.GaplessInfoHolder;
import com.google.android.exoplayer.util.Ac3Util;
import com.google.android.exoplayer.util.Assertions;
import com.google.android.exoplayer.util.CodecSpecificDataUtil;
......@@ -86,11 +86,12 @@ import java.util.List;
*
* @param track Track to which this sample table corresponds.
* @param stblAtom stbl (sample table) atom to parse.
* @param gaplessInfoHolder Holder to populate with gapless playback information.
* @return Sample table described by the stbl atom.
* @throws ParserException If the resulting sample sequence does not contain a sync sample.
*/
public static TrackSampleTable parseStbl(Track track, Atom.ContainerAtom stblAtom)
throws ParserException {
public static TrackSampleTable parseStbl(Track track, Atom.ContainerAtom stblAtom,
GaplessInfoHolder gaplessInfoHolder) throws ParserException {
// Array of sample sizes.
ParsableByteArray stsz = stblAtom.getLeafAtomOfType(Atom.TYPE_stsz).data;
......@@ -257,15 +258,44 @@ import java.util.List;
Assertions.checkArgument(remainingTimestampDeltaChanges == 0);
Assertions.checkArgument(remainingTimestampOffsetChanges == 0);
if (track.editListDurations == null) {
if (track.editListDurations == null || gaplessInfoHolder.hasGaplessInfo()) {
// There is no edit list, or we are ignoring it as we already have gapless metadata to apply.
// This implementation does not support applying both gapless metadata and an edit list.
Util.scaleLargeTimestampsInPlace(timestamps, C.MICROS_PER_SECOND, track.timescale);
return new TrackSampleTable(offsets, sizes, maximumSize, timestamps, flags);
}
// See the BMFF spec (ISO 14496-12) subsection 8.6.6. Edit lists that truncate audio and
// require prerolling from a sync sample after reordering are not supported. This
// implementation handles simple discarding/delaying of samples. The extractor may place
// further restrictions on what edited streams are playable.
// See the BMFF spec (ISO 14496-12) subsection 8.6.6. Edit lists that require prerolling from a
// sync sample after reordering are not supported. Partial audio sample truncation is only
// supported in edit lists with one edit that removes less than one sample from the start/end of
// the track, for gapless audio playback. This implementation handles simple discarding/delaying
// of samples. The extractor may place further restrictions on what edited streams are playable.
if (track.editListDurations.length == 1 && track.type == C.TRACK_TYPE_AUDIO
&& timestamps.length >= 2) {
// Handle the edit by setting gapless playback metadata, if possible. This implementation
// assumes that only one "roll" sample is needed, which is the case for AAC, so the start/end
// points of the edit must lie within the first/last samples respectively.
long editStartTime = track.editListMediaTimes[0];
long editEndTime = editStartTime + Util.scaleLargeTimestamp(track.editListDurations[0],
track.timescale, track.movieTimescale);
long lastSampleEndTime = timestampTimeUnits;
if (timestamps[0] <= editStartTime && editStartTime < timestamps[1]
&& timestamps[timestamps.length - 1] < editEndTime && editEndTime <= lastSampleEndTime) {
long paddingTimeUnits = lastSampleEndTime - editEndTime;
long encoderDelay = Util.scaleLargeTimestamp(editStartTime - timestamps[0],
track.format.sampleRate, track.timescale);
long encoderPadding = Util.scaleLargeTimestamp(paddingTimeUnits,
track.format.sampleRate, track.timescale);
if ((encoderDelay != 0 || encoderPadding != 0) && encoderDelay <= Integer.MAX_VALUE
&& encoderPadding <= Integer.MAX_VALUE) {
gaplessInfoHolder.encoderDelay = (int) encoderDelay;
gaplessInfoHolder.encoderPadding = (int) encoderPadding;
Util.scaleLargeTimestampsInPlace(timestamps, C.MICROS_PER_SECOND, track.timescale);
return new TrackSampleTable(offsets, sizes, maximumSize, timestamps, flags);
}
}
}
if (track.editListDurations.length == 1 && track.editListDurations[0] == 0) {
// The current version of the spec leaves handling of an edit with zero segment_duration in
......@@ -349,13 +379,13 @@ import java.util.List;
*
* @param udtaAtom The udta (user data) atom to parse.
* @param isQuickTime True for QuickTime media. False otherwise.
* @return Gapless playback information stored in the user data, or {@code null} if not present.
* @param out {@link GaplessInfoHolder} to populate with gapless playback information.
*/
public static GaplessInfo parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) {
public static void parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime, GaplessInfoHolder out) {
if (isQuickTime) {
// Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and
// parse one.
return null;
return;
}
ParsableByteArray udtaData = udtaAtom.data;
udtaData.setPosition(Atom.HEADER_SIZE);
......@@ -365,15 +395,14 @@ import java.util.List;
if (atomType == Atom.TYPE_meta) {
udtaData.setPosition(udtaData.getPosition() - Atom.HEADER_SIZE);
udtaData.setLimit(udtaData.getPosition() + atomSize);
return parseMetaAtom(udtaData);
} else {
udtaData.skipBytes(atomSize - Atom.HEADER_SIZE);
parseMetaAtom(udtaData, out);
break;
}
udtaData.skipBytes(atomSize - Atom.HEADER_SIZE);
}
return null;
}
private static GaplessInfo parseMetaAtom(ParsableByteArray data) {
private static void parseMetaAtom(ParsableByteArray data, GaplessInfoHolder out) {
data.skipBytes(Atom.FULL_HEADER_SIZE);
ParsableByteArray ilst = new ParsableByteArray();
while (data.bytesLeft() >= Atom.HEADER_SIZE) {
......@@ -382,17 +411,16 @@ import java.util.List;
if (atomType == Atom.TYPE_ilst) {
ilst.reset(data.data, data.getPosition() + payloadSize);
ilst.setPosition(data.getPosition());
GaplessInfo gaplessInfo = parseIlst(ilst);
if (gaplessInfo != null) {
return gaplessInfo;
parseIlst(ilst, out);
if (out.hasGaplessInfo()) {
return;
}
}
data.skipBytes(payloadSize);
}
return null;
}
private static GaplessInfo parseIlst(ParsableByteArray ilst) {
private static void parseIlst(ParsableByteArray ilst, GaplessInfoHolder out) {
while (ilst.bytesLeft() > 0) {
int position = ilst.getPosition();
int endPosition = position + ilst.readInt();
......@@ -418,13 +446,13 @@ import java.util.List;
}
if (lastCommentName != null && lastCommentData != null
&& "com.apple.iTunes".equals(lastCommentMean)) {
return GaplessInfo.createFromComment(lastCommentName, lastCommentData);
out.setFromComment(lastCommentName, lastCommentData);
break;
}
} else {
ilst.setPosition(endPosition);
}
}
return null;
}
/**
......
......@@ -21,7 +21,7 @@ import com.google.android.exoplayer.ParserException;
import com.google.android.exoplayer.extractor.Extractor;
import com.google.android.exoplayer.extractor.ExtractorInput;
import com.google.android.exoplayer.extractor.ExtractorOutput;
import com.google.android.exoplayer.extractor.GaplessInfo;
import com.google.android.exoplayer.extractor.GaplessInfoHolder;
import com.google.android.exoplayer.extractor.PositionHolder;
import com.google.android.exoplayer.extractor.SeekMap;
import com.google.android.exoplayer.extractor.TrackOutput;
......@@ -297,11 +297,13 @@ public final class Mp4Extractor implements Extractor, SeekMap {
long durationUs = C.UNSET_TIME_US;
List<Mp4Track> tracks = new ArrayList<>();
long earliestSampleOffset = Long.MAX_VALUE;
GaplessInfo gaplessInfo = null;
GaplessInfoHolder gaplessInfoHolder = new GaplessInfoHolder();
Atom.LeafAtom udta = moov.getLeafAtomOfType(Atom.TYPE_udta);
if (udta != null) {
gaplessInfo = AtomParsers.parseUdta(udta, isQuickTime);
AtomParsers.parseUdta(udta, isQuickTime, gaplessInfoHolder);
}
for (int i = 0; i < moov.containerChildren.size(); i++) {
Atom.ContainerAtom atom = moov.containerChildren.get(i);
if (atom.type != Atom.TYPE_trak) {
......@@ -316,7 +318,7 @@ public final class Mp4Extractor implements Extractor, SeekMap {
Atom.ContainerAtom stblAtom = atom.getContainerAtomOfType(Atom.TYPE_mdia)
.getContainerAtomOfType(Atom.TYPE_minf).getContainerAtomOfType(Atom.TYPE_stbl);
TrackSampleTable trackSampleTable = AtomParsers.parseStbl(track, stblAtom);
TrackSampleTable trackSampleTable = AtomParsers.parseStbl(track, stblAtom, gaplessInfoHolder);
if (trackSampleTable.sampleCount == 0) {
continue;
}
......@@ -326,8 +328,9 @@ public final class Mp4Extractor implements Extractor, SeekMap {
// Allow ten source samples per output sample, like the platform extractor.
int maxInputSize = trackSampleTable.maximumSize + 3 * 10;
Format format = track.format.copyWithMaxInputSize(maxInputSize);
if (gaplessInfo != null) {
format = format.copyWithGaplessInfo(gaplessInfo.encoderDelay, gaplessInfo.encoderPadding);
if (track.type == C.TRACK_TYPE_AUDIO && gaplessInfoHolder.hasGaplessInfo()) {
format = format.copyWithGaplessInfo(gaplessInfoHolder.encoderDelay,
gaplessInfoHolder.encoderPadding);
}
mp4Track.trackOutput.format(format);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment