Commit 554817cc by olly Committed by Oliver Woodman

Extract gapless playback data in MP4 files.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=117148015
parent 2380857b
......@@ -297,11 +297,14 @@ public class DefaultExtractorInputTest extends TestCase {
// Check that we read the whole of TEST_DATA.
assertTrue(Arrays.equals(TEST_DATA, target));
assertEquals(0, input.getPosition());
assertEquals(TEST_DATA.length, input.getPeekPosition());
// Check that we can read again from the buffer
byte[] target2 = new byte[TEST_DATA.length];
input.readFully(target2, 0, TEST_DATA.length);
assertTrue(Arrays.equals(TEST_DATA, target2));
assertEquals(TEST_DATA.length, input.getPosition());
assertEquals(TEST_DATA.length, input.getPeekPosition());
// Check that we fail with EOFException if we peek again
try {
......
......@@ -73,7 +73,7 @@ public final class OggReaderTest extends TestCase {
assertEquals(0x02, oggReader.getPageHeader().type);
assertEquals(27 + 1, oggReader.getPageHeader().headerSize);
assertEquals(8, oggReader.getPageHeader().bodySize);
assertEquals(RecordableExtractorInput.STREAM_REVISION, oggReader.getPageHeader().revision);
assertEquals(RecordableOggExtractorInput.STREAM_REVISION, oggReader.getPageHeader().revision);
assertEquals(1, oggReader.getPageHeader().pageSegmentCount);
assertEquals(1000, oggReader.getPageHeader().pageSequenceNumber);
assertEquals(4096, oggReader.getPageHeader().streamSerialNumber);
......
......@@ -25,12 +25,11 @@ import java.io.IOException;
* Implementation of {@link ExtractorInput} for testing purpose.
*/
/* package */ class RecordableExtractorInput implements ExtractorInput {
protected static final byte STREAM_REVISION = 0x00;
private byte[] data;
private int readOffset;
private int writeOffset;
private int peekOffset;
private int readPosition;
private int writePosition;
private int peekPosition;
private boolean throwExceptionsAtRead = false;
private boolean throwExceptionsAtPeek = false;
......@@ -47,12 +46,12 @@ import java.io.IOException;
/**
* Constructs an instance with a initial array of bytes.
*
* @param data the initial data.
* @param writeOffset the {@code writeOffset} from where to start recording.
* @param data The initial data.
* @param writePosition The {@code writePosition} from where to start recording.
*/
public RecordableExtractorInput(byte[] data, int writeOffset) {
public RecordableExtractorInput(byte[] data, int writePosition) {
this.data = data;
this.writeOffset = writeOffset;
this.writePosition = writePosition;
}
/**
......@@ -82,15 +81,15 @@ import java.io.IOException;
readExceptionCounter++;
throw new IOException("deliberately thrown an exception for testing");
}
if (readOffset + length > writeOffset) {
if (readPosition + length > writePosition) {
if (!allowEndOfInput) {
throw new EOFException();
}
return false;
}
System.arraycopy(data, readOffset, target, offset, length);
readOffset += length;
peekOffset = readOffset;
System.arraycopy(data, readPosition, target, offset, length);
readPosition += length;
peekPosition = readPosition;
return true;
}
......@@ -107,20 +106,20 @@ import java.io.IOException;
}
private boolean isEOF() {
return readOffset == writeOffset;
return readPosition == writePosition;
}
@Override
public boolean skipFully(int length, boolean allowEndOfInput)
throws IOException, InterruptedException {
if (readOffset + length >= writeOffset) {
if (readPosition + length >= writePosition) {
if (!allowEndOfInput) {
throw new EOFException();
}
return false;
}
readOffset += length;
peekOffset = readOffset;
readPosition += length;
peekPosition = readPosition;
return true;
}
......@@ -141,14 +140,14 @@ import java.io.IOException;
peekExceptionCounter++;
throw new IOException("deliberately thrown an exception for testing");
}
if (peekOffset + length > writeOffset) {
if (peekPosition + length > writePosition) {
if (!allowEndOfInput) {
throw new EOFException();
}
return false;
}
System.arraycopy(data, peekOffset, target, offset, length);
peekOffset += length;
System.arraycopy(data, peekPosition, target, offset, length);
peekPosition += length;
return true;
}
......@@ -161,13 +160,13 @@ import java.io.IOException;
@Override
public boolean advancePeekPosition(int length, boolean allowEndOfInput)
throws IOException, InterruptedException {
if (peekOffset + length >= writeOffset) {
if (peekPosition + length >= writePosition) {
if (!allowEndOfInput) {
throw new EOFException();
}
return false;
}
peekOffset += length;
peekPosition += length;
return true;
}
......@@ -178,17 +177,22 @@ import java.io.IOException;
@Override
public void resetPeekPosition() {
peekOffset = readOffset;
peekPosition = readPosition;
}
@Override
public long getPeekPosition() {
return peekPosition;
}
@Override
public long getPosition() {
return readOffset;
return readPosition;
}
@Override
public long getLength() {
return writeOffset;
return writePosition;
}
/**
......@@ -197,13 +201,13 @@ import java.io.IOException;
* @param bytes the bytes to record.
*/
public void record(final byte[] bytes) {
System.arraycopy(bytes, 0, data, writeOffset, bytes.length);
writeOffset += bytes.length;
System.arraycopy(bytes, 0, data, writePosition, bytes.length);
writePosition += bytes.length;
}
/** Records a single byte. **/
public void record(byte b) {
record(new byte[]{b});
record(new byte[] {b});
}
/**
......
......@@ -20,6 +20,8 @@ package com.google.android.exoplayer.extractor.ogg;
*/
/* package */ final class RecordableOggExtractorInput extends RecordableExtractorInput {
public static final byte STREAM_REVISION = 0x00;
private long pageSequenceCounter;
public RecordableOggExtractorInput(byte[] data, int writeOffset) {
......
......@@ -148,6 +148,11 @@ public final class DefaultExtractorInput implements ExtractorInput {
}
@Override
public long getPeekPosition() {
return position + peekBufferPosition;
}
@Override
public long getPosition() {
return position;
}
......
......@@ -203,7 +203,14 @@ public interface ExtractorInput {
void resetPeekPosition();
/**
* The current read position (byte offset) in the stream.
* Returns the current peek position (byte offset) in the stream.
*
* @return The peek position (byte offset) in the stream.
*/
long getPeekPosition();
/**
* Returns the current read position (byte offset) in the stream.
*
* @return The read position (byte offset) in the stream.
*/
......
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer.extractor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility for parsing and representing gapless playback information.
*/
public final class GaplessInfo {
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).
*
* @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.
*/
public static GaplessInfo createFromComment(String name, String data) {
if (!GAPLESS_COMMENT_ID.equals(name)) {
return null;
}
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);
} catch (NumberFormatException e) {
// Ignore incorrectly formatted comments.
}
}
return null;
}
/**
* 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.
*/
private GaplessInfo(int encoderDelay, int encoderPadding) {
this.encoderDelay = encoderDelay;
this.encoderPadding = encoderPadding;
}
}
......@@ -16,6 +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.util.ParsableByteArray;
import com.google.android.exoplayer.util.Util;
......@@ -23,8 +24,6 @@ import android.util.Pair;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility for parsing ID3 version 2 metadata in MP3 files.
......@@ -37,9 +36,6 @@ import java.util.regex.Pattern;
private static final int MAXIMUM_METADATA_SIZE = 3 * 1024 * 1024;
private static final int ID3_TAG = Util.getIntegerCodeForString("ID3");
private static final String GAPLESS_COMMENT_NAME = "iTunSMPB";
private static final Pattern GAPLESS_COMMENT_VALUE_PATTERN =
Pattern.compile("^ [0-9a-fA-F]{8} ([0-9a-fA-F]{8}) ([0-9a-fA-F]{8})");
private static final Charset[] CHARSET_BY_ENCODING = new Charset[] {Charset.forName("ISO-8859-1"),
Charset.forName("UTF-16LE"), Charset.forName("UTF-16BE"), Charset.forName("UTF-8")};
......@@ -47,17 +43,15 @@ import java.util.regex.Pattern;
* Peeks data from the input and parses ID3 metadata.
*
* @param input The {@link ExtractorInput} from which data should be peeked.
* @param out {@link Mp3Extractor.Metadata} to populate based on the input.
* @return The number of bytes peeked from the input.
* @return The gapless playback information, if present and non-zero. {@code null} otherwise.
* @throws IOException If an error occurred peeking from the input.
* @throws InterruptedException If the thread was interrupted.
*/
public static int parseId3(ExtractorInput input, Mp3Extractor.Metadata out)
public static GaplessInfo parseId3(ExtractorInput input)
throws IOException, InterruptedException {
out.encoderDelay = 0;
out.encoderPadding = 0;
ParsableByteArray scratch = new ParsableByteArray(10);
int peekedId3Bytes = 0;
GaplessInfo metadata = null;
while (true) {
input.peekFully(scratch.data, 0, 10);
scratch.setPosition(0);
......@@ -69,10 +63,10 @@ import java.util.regex.Pattern;
int minorVersion = scratch.readUnsignedByte();
int flags = scratch.readUnsignedByte();
int length = scratch.readSynchSafeInt();
if (canParseMetadata(majorVersion, minorVersion, flags, length)) {
if (metadata == null && canParseMetadata(majorVersion, minorVersion, flags, length)) {
byte[] frame = new byte[length];
input.peekFully(frame, 0, length);
parseMetadata(new ParsableByteArray(frame), majorVersion, flags, out);
metadata = parseGaplessInfo(new ParsableByteArray(frame), majorVersion, flags);
} else {
input.advancePeekPosition(length);
}
......@@ -81,7 +75,7 @@ import java.util.regex.Pattern;
}
input.resetPeekPosition();
input.advancePeekPosition(peekedId3Bytes);
return peekedId3Bytes;
return metadata;
}
private static boolean canParseMetadata(int majorVersion, int minorVersion, int flags,
......@@ -93,19 +87,18 @@ import java.util.regex.Pattern;
&& !(majorVersion == 4 && (flags & 0x0F) != 0);
}
private static void parseMetadata(ParsableByteArray frame, int version, int flags,
Mp3Extractor.Metadata out) {
private static GaplessInfo parseGaplessInfo(ParsableByteArray frame, int version, int flags) {
unescape(frame, version, flags);
// Skip any extended header.
frame.setPosition(0);
if (version == 3 && (flags & 0x40) != 0) {
if (frame.bytesLeft() < 4) {
return;
return null;
}
int extendedHeaderSize = frame.readUnsignedIntToInt();
if (extendedHeaderSize > frame.bytesLeft()) {
return;
return null;
}
int paddingSize = 0;
if (extendedHeaderSize >= 6) {
......@@ -114,17 +107,17 @@ import java.util.regex.Pattern;
frame.setPosition(4);
frame.setLimit(frame.limit() - paddingSize);
if (frame.bytesLeft() < extendedHeaderSize) {
return;
return null;
}
}
frame.skipBytes(extendedHeaderSize);
} else if (version == 4 && (flags & 0x40) != 0) {
if (frame.bytesLeft() < 4) {
return;
return null;
}
int extendedHeaderSize = frame.readSynchSafeInt();
if (extendedHeaderSize < 6 || extendedHeaderSize > frame.bytesLeft() + 4) {
return;
return null;
}
frame.setPosition(extendedHeaderSize);
}
......@@ -132,20 +125,15 @@ import java.util.regex.Pattern;
// Extract gapless playback metadata stored in comments.
Pair<String, String> comment;
while ((comment = findNextComment(version, frame)) != null) {
if (comment.first.length() > 3 && comment.first.substring(3).equals(GAPLESS_COMMENT_NAME)) {
Matcher matcher = GAPLESS_COMMENT_VALUE_PATTERN.matcher(comment.second);
if (matcher.find()) {
try {
out.encoderDelay = Integer.parseInt(matcher.group(1), 16);
out.encoderPadding = Integer.parseInt(matcher.group(2), 16);
break;
} catch (NumberFormatException e) {
out.encoderDelay = 0;
return;
}
if (comment.first.length() > 3) {
GaplessInfo gaplessInfo =
GaplessInfo.createFromComment(comment.first.substring(3), comment.second);
if (gaplessInfo != null) {
return gaplessInfo;
}
}
}
return null;
}
private static Pair<String, String> findNextComment(int majorVersion, ParsableByteArray data) {
......
......@@ -21,6 +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.PositionHolder;
import com.google.android.exoplayer.extractor.SeekMap;
import com.google.android.exoplayer.extractor.TrackOutput;
......@@ -56,7 +57,6 @@ public final class Mp3Extractor implements Extractor {
private final long forcedFirstSampleTimestampUs;
private final ParsableByteArray scratch;
private final MpegAudioHeader synchronizedHeader;
private final Metadata metadata;
// Extractor outputs.
private ExtractorOutput extractorOutput;
......@@ -64,6 +64,7 @@ public final class Mp3Extractor implements Extractor {
private int synchronizedHeaderData;
private GaplessInfo gaplessInfo;
private Seeker seeker;
private long basisTimeUs;
private int samplesRead;
......@@ -86,7 +87,6 @@ public final class Mp3Extractor implements Extractor {
this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs;
scratch = new ParsableByteArray(4);
synchronizedHeader = new MpegAudioHeader();
metadata = new Metadata();
basisTimeUs = -1;
}
......@@ -194,11 +194,15 @@ public final class Mp3Extractor implements Extractor {
private boolean synchronize(ExtractorInput input, boolean sniffing)
throws IOException, InterruptedException {
input.resetPeekPosition();
int searched = 0;
int validFrameCount = 0;
int candidateSynchronizedHeaderData = 0;
int peekedId3Bytes = input.getPosition() == 0 ? Id3Util.parseId3(input, metadata) : 0;
int peekedId3Bytes = 0;
input.resetPeekPosition();
if (input.getPosition() == 0) {
gaplessInfo = Id3Util.parseId3(input);
peekedId3Bytes = (int) input.getPeekPosition();
}
while (true) {
if (sniffing && searched == MAX_SNIFF_BYTES) {
return false;
......@@ -274,15 +278,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 && metadata.encoderDelay == 0 && metadata.encoderPadding == 0) {
if (seeker != null && gaplessInfo == null) {
// 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);
int gaplessMetadata = scratch.readUnsignedInt24();
metadata.encoderDelay = gaplessMetadata >> 12;
metadata.encoderPadding = gaplessMetadata & 0x0FFF;
gaplessInfo = GaplessInfo.createFromXingHeaderValue(scratch.readUnsignedInt24());
}
input.skipFully(synchronizedHeader.frameSize);
} else {
......@@ -322,11 +324,4 @@ public final class Mp3Extractor implements Extractor {
}
/* package */ static final class Metadata {
public int encoderDelay;
public int encoderPadding;
}
}
......@@ -114,6 +114,13 @@ import java.util.List;
public static final int TYPE_stpp = Util.getIntegerCodeForString("stpp");
public static final int TYPE_samr = Util.getIntegerCodeForString("samr");
public static final int TYPE_sawb = Util.getIntegerCodeForString("sawb");
public static final int TYPE_udta = Util.getIntegerCodeForString("udta");
public static final int TYPE_meta = Util.getIntegerCodeForString("meta");
public static final int TYPE_ilst = Util.getIntegerCodeForString("ilst");
public static final int TYPE_mean = Util.getIntegerCodeForString("mean");
public static final int TYPE_name = Util.getIntegerCodeForString("name");
public static final int TYPE_data = Util.getIntegerCodeForString("data");
public static final int TYPE_DASHES = Util.getIntegerCodeForString("----");
public final int type;
......
......@@ -17,6 +17,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.extractor.GaplessInfo;
import com.google.android.exoplayer.util.Ac3Util;
import com.google.android.exoplayer.util.Assertions;
import com.google.android.exoplayer.util.CodecSpecificDataUtil;
......@@ -331,6 +332,71 @@ import java.util.List;
}
/**
* Parses a udta atom.
*
* @param udtaAtom The udta (user data) atom to parse.
* @return Gapless playback information stored in the user data, or {@code null} if not present.
*/
public static GaplessInfo parseUdta(Atom.ContainerAtom udtaAtom) {
Atom.LeafAtom metaAtom = udtaAtom.getLeafAtomOfType(Atom.TYPE_meta);
if (metaAtom == null) {
return null;
}
ParsableByteArray data = metaAtom.data;
data.setPosition(Atom.FULL_HEADER_SIZE);
ParsableByteArray ilst = new ParsableByteArray();
while (data.bytesLeft() > 0) {
int length = data.readInt() - Atom.HEADER_SIZE;
int type = data.readInt();
if (type == Atom.TYPE_ilst) {
ilst.reset(data.data, data.getPosition() + length);
ilst.setPosition(data.getPosition());
GaplessInfo gaplessInfo = parseIlst(ilst);
if (gaplessInfo != null) {
return gaplessInfo;
}
}
data.skipBytes(length);
}
return null;
}
private static GaplessInfo parseIlst(ParsableByteArray ilst) {
while (ilst.bytesLeft() > 0) {
int position = ilst.getPosition();
int endPosition = position + ilst.readInt();
int type = ilst.readInt();
if (type == Atom.TYPE_DASHES) {
String lastCommentMean = null;
String lastCommentName = null;
String lastCommentData = null;
while (ilst.getPosition() < endPosition) {
int length = ilst.readInt() - Atom.FULL_HEADER_SIZE;
int key = ilst.readInt();
ilst.skipBytes(4);
if (key == Atom.TYPE_mean) {
lastCommentMean = ilst.readString(length);
} else if (key == Atom.TYPE_name) {
lastCommentName = ilst.readString(length);
} else if (key == Atom.TYPE_data) {
ilst.skipBytes(4);
lastCommentData = ilst.readString(length - 4);
} else {
ilst.skipBytes(length);
}
}
if (lastCommentName != null && lastCommentData != null
&& "com.apple.iTunes".equals(lastCommentMean)) {
return GaplessInfo.createFromComment(lastCommentName, lastCommentData);
}
} else {
ilst.setPosition(endPosition);
}
}
return null;
}
/**
* Parses a mvhd atom (defined in 14496-12), returning the timescale for the movie.
*
* @param mvhd Contents of the mvhd atom to be parsed.
......
......@@ -275,11 +275,19 @@ public final class Mp4Extractor implements Extractor, SeekMap {
return false;
}
/** Updates the stored track metadata to reflect the contents of the specified moov atom. */
/**
* Updates the stored track metadata to reflect the contents of the specified moov atom.
*/
private void processMoovAtom(ContainerAtom moov) {
long durationUs = C.UNKNOWN_TIME_US;
List<Mp4Track> tracks = new ArrayList<>();
long earliestSampleOffset = Long.MAX_VALUE;
// TODO: Apply gapless information.
// GaplessInfo gaplessInfo = null;
// Atom.ContainerAtom udta = moov.getContainerAtomOfType(Atom.TYPE_udta);
// if (udta != null) {
// gaplessInfo = AtomParsers.parseUdta(udta);
// }
for (int i = 0; i < moov.containerChildren.size(); i++) {
Atom.ContainerAtom atom = moov.containerChildren.get(i);
if (atom.type != Atom.TYPE_trak) {
......@@ -421,19 +429,24 @@ public final class Mp4Extractor implements Extractor, SeekMap {
return earliestSampleTrackIndex;
}
/** Returns whether the extractor should parse a leaf atom with type {@code atom}. */
/**
* Returns whether the extractor should parse a leaf atom with type {@code atom}.
*/
private static boolean shouldParseLeafAtom(int atom) {
return atom == Atom.TYPE_mdhd || atom == Atom.TYPE_mvhd || atom == Atom.TYPE_hdlr
|| atom == Atom.TYPE_stsd || atom == Atom.TYPE_stts || atom == Atom.TYPE_stss
|| atom == Atom.TYPE_ctts || atom == Atom.TYPE_elst || atom == Atom.TYPE_stsc
|| atom == Atom.TYPE_stsz || atom == Atom.TYPE_stco || atom == Atom.TYPE_co64
|| atom == Atom.TYPE_tkhd || atom == Atom.TYPE_ftyp;
|| atom == Atom.TYPE_tkhd || atom == Atom.TYPE_ftyp || atom == Atom.TYPE_meta;
}
/** Returns whether the extractor should parse a container atom with type {@code atom}. */
/**
* Returns whether the extractor should parse a container atom with type {@code atom}.
*/
private static boolean shouldParseContainerAtom(int atom) {
return atom == Atom.TYPE_moov || atom == Atom.TYPE_trak || atom == Atom.TYPE_mdia
|| atom == Atom.TYPE_minf || atom == Atom.TYPE_stbl || atom == Atom.TYPE_edts;
|| atom == Atom.TYPE_minf || atom == Atom.TYPE_stbl || atom == Atom.TYPE_edts
|| atom == Atom.TYPE_udta;
}
private static final class Mp4Track {
......
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