mirror of
https://github.com/samsonjs/media.git
synced 2026-03-31 10:25:48 +00:00
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:
parent
25fb2a826e
commit
588d5a6e55
4 changed files with 342 additions and 22 deletions
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue