diff --git a/library/src/androidTest/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java b/library/src/androidTest/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java index 53fa41c5f3..eed72f38f7 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/util/ParsableByteArrayTest.java @@ -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()); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Id3Util.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Id3Util.java new file mode 100644 index 0000000000..a4ad79a85d --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Id3Util.java @@ -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 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 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() {} + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java index 22c6137466..a5079670bf 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java @@ -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; diff --git a/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java b/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java index 32c9d6c8d7..ccf5033caf 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java +++ b/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java @@ -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; + } + }