Parse encoder delay and padding from ID3 metadata in MP3.

Based on AOSP's MP3Extractor.cpp and ID3.cpp.

Issue: #497
-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=112685664
This commit is contained in:
andrewlewis 2016-01-21 08:01:04 -08:00 committed by Oliver Woodman
parent 25fb2a826e
commit 588d5a6e55
4 changed files with 342 additions and 22 deletions

View file

@ -18,6 +18,7 @@ package com.google.android.exoplayer.util;
import junit.framework.TestCase;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.Arrays;
/**
@ -415,4 +416,11 @@ public class ParsableByteArrayTest extends TestCase {
assertNull(parser.readLine());
}
public void testReadString() {
byte[] bytes = new byte[] {'t', 'e', 's', 't'};
ParsableByteArray testArray = new ParsableByteArray(bytes);
assertEquals("test", testArray.readString(bytes.length, Charset.forName("UTF-8")));
assertEquals(bytes.length, testArray.getPosition());
}
}

View file

@ -0,0 +1,317 @@
/*
* 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.mp3;
import com.google.android.exoplayer.extractor.ExtractorInput;
import com.google.android.exoplayer.util.ParsableByteArray;
import com.google.android.exoplayer.util.Util;
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.
*/
/* package */ final class Id3Util {
public static final class Metadata {
public int encoderDelay;
public int encoderPadding;
}
/**
* The maximum valid length for metadata in bytes.
*/
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")};
/**
* Peeks data from the input and parses ID3 metadata.
*
* @param input The {@link ExtractorInput} from which data should be peeked.
* @param out {@link Metadata} to populate based on the input.
* @return The number of bytes peeked from the input.
* @throws IOException If an error occurred peeking from the input.
* @throws InterruptedException If the thread was interrupted.
*/
public static int parseId3(ExtractorInput input, Metadata out)
throws IOException, InterruptedException {
out.encoderDelay = 0;
out.encoderPadding = 0;
ParsableByteArray scratch = new ParsableByteArray(10);
int peekedId3Bytes = 0;
while (true) {
input.peekFully(scratch.data, 0, 10);
scratch.setPosition(0);
if (scratch.readUnsignedInt24() != ID3_TAG) {
break;
}
int majorVersion = scratch.readUnsignedByte();
int minorVersion = scratch.readUnsignedByte();
int flags = scratch.readUnsignedByte();
int length = scratch.readSynchSafeInt();
if (canParseMetadata(majorVersion, minorVersion, flags, length)) {
byte[] frame = new byte[length];
input.peekFully(frame, 0, length);
parseMetadata(new ParsableByteArray(frame), majorVersion, flags, out);
} else {
input.advancePeekPosition(length);
}
peekedId3Bytes += 10 + length;
}
input.resetPeekPosition();
input.advancePeekPosition(peekedId3Bytes);
return peekedId3Bytes;
}
private static boolean canParseMetadata(int majorVersion, int minorVersion, int flags,
int length) {
return minorVersion != 0xFF && majorVersion >= 2 && majorVersion <= 4
&& length <= MAXIMUM_METADATA_SIZE
&& !(majorVersion == 2 && ((flags & 0x3F) != 0 || (flags & 0x40) != 0))
&& !(majorVersion == 3 && (flags & 0x1F) != 0)
&& !(majorVersion == 4 && (flags & 0x0F) != 0);
}
private static void parseMetadata(ParsableByteArray frame, int version, int flags, Metadata out) {
unescape(frame, version, flags);
// Skip any extended header.
frame.setPosition(0);
if (version == 3 && (flags & 0x40) != 0) {
if (frame.bytesLeft() < 4) {
return;
}
int extendedHeaderSize = frame.readUnsignedIntToInt();
if (extendedHeaderSize > frame.bytesLeft()) {
return;
}
int paddingSize = 0;
if (extendedHeaderSize >= 6) {
frame.skipBytes(2); // extended flags
paddingSize = frame.readUnsignedIntToInt();
frame.setPosition(4);
frame.setLimit(frame.limit() - paddingSize);
if (frame.bytesLeft() < extendedHeaderSize) {
return;
}
}
frame.skipBytes(extendedHeaderSize);
} else if (version == 4 && (flags & 0x40) != 0) {
if (frame.bytesLeft() < 4) {
return;
}
int extendedHeaderSize = frame.readSynchSafeInt();
if (extendedHeaderSize < 6 || extendedHeaderSize > frame.bytesLeft() + 4) {
return;
}
frame.setPosition(extendedHeaderSize);
}
// 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;
}
}
}
}
}
private static Pair<String, String> findNextComment(int majorVersion, ParsableByteArray data) {
int frameSize;
while (true) {
if (majorVersion == 2) {
if (data.bytesLeft() < 6) {
return null;
}
String id = data.readString(3, Charset.forName("US-ASCII"));
if (id.equals("\0\0\0")) {
return null;
}
frameSize = data.readUnsignedInt24();
if (frameSize == 0 || frameSize > data.bytesLeft()) {
return null;
}
if (id.equals("COM")) {
break;
}
} else /* major == 3 || major == 4 */ {
if (data.bytesLeft() < 10) {
return null;
}
String id = data.readString(4, Charset.forName("US-ASCII"));
if (id.equals("\0\0\0\0")) {
return null;
}
frameSize = majorVersion == 4 ? data.readSynchSafeInt() : data.readUnsignedIntToInt();
if (frameSize == 0 || frameSize > data.bytesLeft() - 2) {
return null;
}
int flags = data.readUnsignedShort();
boolean compressedOrEncrypted = (majorVersion == 4 && (flags & 0x0C) != 0)
|| (majorVersion == 3 && (flags & 0xC0) != 0);
if (!compressedOrEncrypted && id.equals("COMM")) {
break;
}
}
data.skipBytes(frameSize);
}
// The comment tag is at the reading position in data.
int encoding = data.readUnsignedByte();
if (encoding < 0 || encoding >= CHARSET_BY_ENCODING.length) {
return null;
}
Charset charset = CHARSET_BY_ENCODING[encoding];
String[] commentFields = data.readString(frameSize - 1, charset).split("\0");
return commentFields.length == 2 ? Pair.create(commentFields[0], commentFields[1]) : null;
}
private static boolean unescape(ParsableByteArray frame, int version, int flags) {
if (version != 4) {
if ((flags & 0x80) != 0) {
// Remove unsynchronization on ID3 version < 2.4.0.
byte[] bytes = frame.data;
int newLength = bytes.length;
for (int i = 0; i + 1 < newLength; i++) {
if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) {
System.arraycopy(bytes, i + 2, bytes, i + 1, newLength - i - 2);
newLength--;
}
}
frame.setLimit(newLength);
}
} else {
// Remove unsynchronization on ID3 version 2.4.0.
if (canUnescapeVersion4(frame, false)) {
unescapeVersion4(frame, false);
} else if (canUnescapeVersion4(frame, true)) {
unescapeVersion4(frame, true);
} else {
return false;
}
}
return true;
}
private static boolean canUnescapeVersion4(ParsableByteArray frame,
boolean unsignedIntDataSizeHack) {
frame.setPosition(0);
while (frame.bytesLeft() >= 10) {
if (frame.readInt() == 0) {
return true;
}
long dataSize = frame.readUnsignedInt();
if (!unsignedIntDataSizeHack) {
// Parse the data size as a syncsafe integer.
if ((dataSize & 0x808080L) != 0) {
return false;
}
dataSize = (dataSize & 0x7F) | (((dataSize >> 8) & 0x7F) << 7)
| (((dataSize >> 16) & 0x7F) << 14) | (((dataSize >> 24) & 0x7F) << 21);
}
if (dataSize > frame.bytesLeft() - 2) {
return false;
}
int flags = frame.readUnsignedShort();
if ((flags & 1) != 0) {
if (frame.bytesLeft() < 4) {
return false;
}
}
frame.skipBytes((int) dataSize);
}
return true;
}
private static void unescapeVersion4(ParsableByteArray frame, boolean unsignedIntDataSizeHack) {
frame.setPosition(0);
byte[] bytes = frame.data;
while (frame.bytesLeft() >= 10) {
if (frame.readInt() == 0) {
return;
}
int dataSize =
unsignedIntDataSizeHack ? frame.readUnsignedIntToInt() : frame.readSynchSafeInt();
int flags = frame.readUnsignedShort();
int previousFlags = flags;
if ((flags & 1) != 0) {
// Strip data length indicator.
int offset = frame.getPosition();
System.arraycopy(bytes, offset + 4, bytes, offset, frame.bytesLeft() - 4);
dataSize -= 4;
flags &= ~1;
frame.setLimit(frame.limit() - 4);
}
if ((flags & 2) != 0) {
// Unescape 0xFF00 to 0xFF in the next dataSize bytes.
int readOffset = frame.getPosition() + 1;
int writeOffset = readOffset;
for (int i = 0; i + 1 < dataSize; i++) {
if ((bytes[readOffset - 1] & 0xFF) == 0xFF && bytes[readOffset] == 0) {
readOffset++;
dataSize--;
}
bytes[writeOffset++] = bytes[readOffset++];
}
frame.setLimit(frame.limit() - (readOffset - writeOffset));
System.arraycopy(bytes, readOffset, bytes, writeOffset, frame.bytesLeft() - readOffset);
flags &= ~2;
}
if (flags != previousFlags || unsignedIntDataSizeHack) {
int dataSizeOffset = frame.getPosition() - 6;
writeSyncSafeInteger(bytes, dataSizeOffset, dataSize);
bytes[dataSizeOffset + 4] = (byte) (flags >> 8);
bytes[dataSizeOffset + 5] = (byte) (flags & 0xFF);
}
frame.skipBytes(dataSize);
}
}
private static void writeSyncSafeInteger(byte[] bytes, int offset, int value) {
bytes[offset] = (byte) ((value >> 21) & 0x7F);
bytes[offset + 1] = (byte) ((value >> 14) & 0x7F);
bytes[offset + 2] = (byte) ((value >> 7) & 0x7F);
bytes[offset + 3] = (byte) (value & 0x7F);
}
private Id3Util() {}
}

View file

@ -49,7 +49,6 @@ public final class Mp3Extractor implements Extractor {
* Mask that includes the audio header values that must match between frames.
*/
private static final int HEADER_MASK = 0xFFFE0C00;
private static final int ID3_TAG = Util.getIntegerCodeForString("ID3");
private static final int XING_HEADER = Util.getIntegerCodeForString("Xing");
private static final int INFO_HEADER = Util.getIntegerCodeForString("Info");
private static final int VBRI_HEADER = Util.getIntegerCodeForString("VBRI");
@ -57,6 +56,7 @@ public final class Mp3Extractor implements Extractor {
private final long forcedFirstSampleTimestampUs;
private final ParsableByteArray scratch;
private final MpegAudioHeader synchronizedHeader;
private final Id3Util.Metadata metadata;
// Extractor outputs.
private ExtractorOutput extractorOutput;
@ -86,6 +86,7 @@ public final class Mp3Extractor implements Extractor {
this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs;
scratch = new ParsableByteArray(4);
synchronizedHeader = new MpegAudioHeader();
metadata = new Id3Util.Metadata();
basisTimeUs = -1;
}
@ -194,30 +195,10 @@ public final class Mp3Extractor implements Extractor {
private boolean synchronize(ExtractorInput input, boolean sniffing)
throws IOException, InterruptedException {
input.resetPeekPosition();
int peekedId3Bytes = 0;
if (input.getPosition() == 0) {
// Skip any ID3 headers at the start of the file.
while (true) {
input.peekFully(scratch.data, 0, 3);
scratch.setPosition(0);
if (scratch.readUnsignedInt24() != ID3_TAG) {
break;
}
input.advancePeekPosition(2 + 1); // version, flags
input.peekFully(scratch.data, 0, 4);
scratch.setPosition(0);
int headerLength = scratch.readSynchSafeInt();
input.advancePeekPosition(headerLength);
peekedId3Bytes += 10 + headerLength;
}
input.resetPeekPosition();
input.advancePeekPosition(peekedId3Bytes);
}
// For sniffing, limit the search range to the length of an audio frame after any ID3 metadata.
int searched = 0;
int validFrameCount = 0;
int candidateSynchronizedHeaderData = 0;
int peekedId3Bytes = input.getPosition() == 0 ? Id3Util.parseId3(input, metadata) : 0;
while (true) {
if (sniffing && searched == MAX_SNIFF_BYTES) {
return false;

View file

@ -16,6 +16,7 @@
package com.google.android.exoplayer.util;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
/**
* Wraps a byte array, providing a set of methods for parsing data from it. Numerical values are
@ -358,4 +359,17 @@ public final class ParsableByteArray {
return line;
}
/**
* Reads the next {@code bytes} bytes as characters in the specified {@link Charset}.
*
* @param bytes The number of bytes to read.
* @param charset The character set of the encoded characters.
* @return The string encoded by the bytes in the specified character set.
*/
public String readString(int bytes, Charset charset) {
String result = new String(data, position, bytes, charset);
position += bytes;
return result;
}
}