diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7a178449eb..a8929bc156 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -30,6 +30,8 @@ * Allow setting tags for all media sources in their factories. The tag of the current window can be retrieved with `ExoPlayer.getCurrentTag`. * Audio: + * FLAC: Sniff FLAC files correctly if they have ID3 headers + ([#4055](https://github.com/google/ExoPlayer/issues/4055)). * Factor out `AudioTrack` position tracking from `DefaultAudioSink`. * Fix an issue where the playback position would pause just after playback begins, and poll the audio timestamp less frequently once it starts diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle index f617064ce5..9bf8d39435 100644 --- a/extensions/flac/build.gradle +++ b/extensions/flac/build.gradle @@ -31,6 +31,7 @@ android { } dependencies { + implementation 'com.android.support:support-annotations:' + supportLibraryVersion implementation project(modulePrefix + 'library-core') androidTestImplementation project(modulePrefix + 'testutils') } diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac b/extensions/flac/src/androidTest/assets/bear_with_id3.flac new file mode 100644 index 0000000000..fc945f14ad Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear_with_id3.flac differ diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump new file mode 100644 index 0000000000..d8903fcade --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump @@ -0,0 +1,162 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 526272 + sample count = 33 + sample 0: + time = 0 + flags = 1 + data = length 16384, hash 61D2C5C2 + sample 1: + time = 85333 + flags = 1 + data = length 16384, hash E6D7F214 + sample 2: + time = 170666 + flags = 1 + data = length 16384, hash 59BF0D5D + sample 3: + time = 256000 + flags = 1 + data = length 16384, hash 3625F468 + sample 4: + time = 341333 + flags = 1 + data = length 16384, hash F66A323 + sample 5: + time = 426666 + flags = 1 + data = length 16384, hash CDBAE629 + sample 6: + time = 512000 + flags = 1 + data = length 16384, hash 536F3A91 + sample 7: + time = 597333 + flags = 1 + data = length 16384, hash D4F35C9C + sample 8: + time = 682666 + flags = 1 + data = length 16384, hash EE04CEBF + sample 9: + time = 768000 + flags = 1 + data = length 16384, hash 647E2A67 + sample 10: + time = 853333 + flags = 1 + data = length 16384, hash 31583F2C + sample 11: + time = 938666 + flags = 1 + data = length 16384, hash E433A93D + sample 12: + time = 1024000 + flags = 1 + data = length 16384, hash 5E1C7051 + sample 13: + time = 1109333 + flags = 1 + data = length 16384, hash 43E6E358 + sample 14: + time = 1194666 + flags = 1 + data = length 16384, hash 5DC1B256 + sample 15: + time = 1280000 + flags = 1 + data = length 16384, hash 3D9D95CF + sample 16: + time = 1365333 + flags = 1 + data = length 16384, hash 2A5BD2C0 + sample 17: + time = 1450666 + flags = 1 + data = length 16384, hash 93E25061 + sample 18: + time = 1536000 + flags = 1 + data = length 16384, hash B81793D8 + sample 19: + time = 1621333 + flags = 1 + data = length 16384, hash 1A3BD49F + sample 20: + time = 1706666 + flags = 1 + data = length 16384, hash FB672FF1 + sample 21: + time = 1792000 + flags = 1 + data = length 16384, hash 48AB8B45 + sample 22: + time = 1877333 + flags = 1 + data = length 16384, hash 13C9640A + sample 23: + time = 1962666 + flags = 1 + data = length 16384, hash 499E4A0B + sample 24: + time = 2048000 + flags = 1 + data = length 16384, hash F9A783E6 + sample 25: + time = 2133333 + flags = 1 + data = length 16384, hash D2B77598 + sample 26: + time = 2218666 + flags = 1 + data = length 16384, hash CE5B826C + sample 27: + time = 2304000 + flags = 1 + data = length 16384, hash E99EE956 + sample 28: + time = 2389333 + flags = 1 + data = length 16384, hash F2DB1486 + sample 29: + time = 2474666 + flags = 1 + data = length 16384, hash 1636EAB + sample 30: + time = 2560000 + flags = 1 + data = length 16384, hash 23457C08 + sample 31: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 32: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump new file mode 100644 index 0000000000..100fdd1eaf --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump @@ -0,0 +1,122 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 362432 + sample count = 23 + sample 0: + time = 853333 + flags = 1 + data = length 16384, hash 31583F2C + sample 1: + time = 938666 + flags = 1 + data = length 16384, hash E433A93D + sample 2: + time = 1024000 + flags = 1 + data = length 16384, hash 5E1C7051 + sample 3: + time = 1109333 + flags = 1 + data = length 16384, hash 43E6E358 + sample 4: + time = 1194666 + flags = 1 + data = length 16384, hash 5DC1B256 + sample 5: + time = 1280000 + flags = 1 + data = length 16384, hash 3D9D95CF + sample 6: + time = 1365333 + flags = 1 + data = length 16384, hash 2A5BD2C0 + sample 7: + time = 1450666 + flags = 1 + data = length 16384, hash 93E25061 + sample 8: + time = 1536000 + flags = 1 + data = length 16384, hash B81793D8 + sample 9: + time = 1621333 + flags = 1 + data = length 16384, hash 1A3BD49F + sample 10: + time = 1706666 + flags = 1 + data = length 16384, hash FB672FF1 + sample 11: + time = 1792000 + flags = 1 + data = length 16384, hash 48AB8B45 + sample 12: + time = 1877333 + flags = 1 + data = length 16384, hash 13C9640A + sample 13: + time = 1962666 + flags = 1 + data = length 16384, hash 499E4A0B + sample 14: + time = 2048000 + flags = 1 + data = length 16384, hash F9A783E6 + sample 15: + time = 2133333 + flags = 1 + data = length 16384, hash D2B77598 + sample 16: + time = 2218666 + flags = 1 + data = length 16384, hash CE5B826C + sample 17: + time = 2304000 + flags = 1 + data = length 16384, hash E99EE956 + sample 18: + time = 2389333 + flags = 1 + data = length 16384, hash F2DB1486 + sample 19: + time = 2474666 + flags = 1 + data = length 16384, hash 1636EAB + sample 20: + time = 2560000 + flags = 1 + data = length 16384, hash 23457C08 + sample 21: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 22: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump new file mode 100644 index 0000000000..6c3cd731b3 --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump @@ -0,0 +1,78 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 182208 + sample count = 12 + sample 0: + time = 1792000 + flags = 1 + data = length 16384, hash 48AB8B45 + sample 1: + time = 1877333 + flags = 1 + data = length 16384, hash 13C9640A + sample 2: + time = 1962666 + flags = 1 + data = length 16384, hash 499E4A0B + sample 3: + time = 2048000 + flags = 1 + data = length 16384, hash F9A783E6 + sample 4: + time = 2133333 + flags = 1 + data = length 16384, hash D2B77598 + sample 5: + time = 2218666 + flags = 1 + data = length 16384, hash CE5B826C + sample 6: + time = 2304000 + flags = 1 + data = length 16384, hash E99EE956 + sample 7: + time = 2389333 + flags = 1 + data = length 16384, hash F2DB1486 + sample 8: + time = 2474666 + flags = 1 + data = length 16384, hash 1636EAB + sample 9: + time = 2560000 + flags = 1 + data = length 16384, hash 23457C08 + sample 10: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 11: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump new file mode 100644 index 0000000000..decf9c6af3 --- /dev/null +++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump @@ -0,0 +1,38 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=55284]] +numberOfTracks = 1 +track 0: + format: + bitrate = 768000 + id = null + containerMimeType = null + sampleMimeType = audio/raw + maxInputSize = 16384 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 2 + sampleRate = 48000 + pcmEncoding = 2 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 18368 + sample count = 2 + sample 0: + time = 2645333 + flags = 1 + data = length 16384, hash 30EB8381 + sample 1: + time = 2730666 + flags = 1 + data = length 1984, hash 59CFDE1B +tracksEnded = true diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java index c5f1f5c146..fc9bdac2ea 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java @@ -33,7 +33,7 @@ public class FlacExtractorTest extends InstrumentationTestCase { } } - public void testSample() throws Exception { + public void testExtractFlacSample() throws Exception { ExtractorAsserts.assertBehavior( new ExtractorFactory() { @Override @@ -44,4 +44,16 @@ public class FlacExtractorTest extends InstrumentationTestCase { "bear.flac", getInstrumentation().getContext()); } + + public void testExtractFlacSampleWithId3Header() throws Exception { + ExtractorAsserts.assertBehavior( + new ExtractorFactory() { + @Override + public Extractor create() { + return new FlacExtractor(); + } + }, + "bear_with_id3.flac", + getInstrumentation().getContext()); + } } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 6859b44877..729a406315 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -17,20 +17,27 @@ package com.google.android.exoplayer2.ext.flac; import static com.google.android.exoplayer2.util.Util.getPcmEncoding; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.Id3Peeker; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekPoint; import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.FlacStreamInfo; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; import java.util.Arrays; @@ -51,22 +58,57 @@ public final class FlacExtractor implements Extractor { }; + /** Flags controlling the behavior of the extractor. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_ID3_METADATA} + ) + public @interface Flags {} + + /** + * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not + * required. + */ + public static final int FLAG_DISABLE_ID3_METADATA = 1; + /** * FLAC signature: first 4 is the signature word, second 4 is the sizeof STREAMINFO. 0x22 is the * mandatory STREAMINFO. */ private static final byte[] FLAC_SIGNATURE = {'f', 'L', 'a', 'C', 0, 0, 0, 0x22}; - private ExtractorOutput extractorOutput; - private TrackOutput trackOutput; + private final Id3Peeker id3Peeker; + private final @Flags int flags; private FlacDecoderJni decoderJni; - private boolean metadataParsed; + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; private ParsableByteArray outputBuffer; private ByteBuffer outputByteBuffer; + private Metadata id3Metadata; + private long id3SectionSize; + + private boolean metadataParsed; + + /** Constructs an instance with flags = 0. */ + public FlacExtractor() { + this(0); + } + + /** + * Constructs an instance. + * + * @param flags Flags that control the extractor's behavior. + */ + public FlacExtractor(int flags) { + this.flags = flags; + id3Peeker = new Id3Peeker(); + } + @Override public void init(ExtractorOutput output) { extractorOutput = output; @@ -81,14 +123,27 @@ public final class FlacExtractor implements Extractor { @Override public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { - byte[] header = new byte[FLAC_SIGNATURE.length]; - input.peekFully(header, 0, FLAC_SIGNATURE.length); - return Arrays.equals(header, FLAC_SIGNATURE); + if (input.getPosition() == 0) { + id3Metadata = peekId3Data(input); + id3SectionSize = input.getPeekPosition(); + } + boolean isFlacFormat = peekFlacSignature(input); + if (isFlacFormat) { + // If this is FLAC format, we should skip the whole ID3 section. + skipFullyId3Section(input); + } + return isFlacFormat; } @Override public int read(final ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { + if (input.getPosition() == 0) { + id3Metadata = peekId3Data(input); + id3SectionSize = input.getPeekPosition(); + } + skipFullyId3Section(input); + decoderJni.setData(input); if (!metadataParsed) { @@ -100,7 +155,7 @@ public final class FlacExtractor implements Extractor { } } catch (IOException e) { decoderJni.reset(0); - input.setRetryPosition(0, e); + input.setRetryPosition(id3SectionSize, e); throw e; // never executes } metadataParsed = true; @@ -108,22 +163,25 @@ public final class FlacExtractor implements Extractor { boolean isSeekable = decoderJni.getSeekPosition(0) != -1; extractorOutput.seekMap( isSeekable - ? new FlacSeekMap(streamInfo.durationUs(), decoderJni) + ? new FlacSeekMap(streamInfo.durationUs(), decoderJni, id3SectionSize) : new SeekMap.Unseekable(streamInfo.durationUs(), 0)); Format mediaFormat = Format.createAudioSampleFormat( - null, + /* id= */ null, MimeTypes.AUDIO_RAW, - null, + /* codecs= */ null, streamInfo.bitRate(), streamInfo.maxDecodedFrameSize(), streamInfo.channels, streamInfo.sampleRate, getPcmEncoding(streamInfo.bitsPerSample), - null, - null, - 0, - null); + /* encoderDelay= */ 0, + /* encoderPadding= */ 0, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null, + (flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : id3Metadata); trackOutput.format(mediaFormat); outputBuffer = new ParsableByteArray(streamInfo.maxDecodedFrameSize()); @@ -138,7 +196,7 @@ public final class FlacExtractor implements Extractor { } catch (IOException e) { if (lastDecodePosition >= 0) { decoderJni.reset(lastDecodePosition); - input.setRetryPosition(lastDecodePosition, e); + input.setRetryPosition(id3SectionSize + lastDecodePosition, e); } throw e; } @@ -154,11 +212,12 @@ public final class FlacExtractor implements Extractor { @Override public void seek(long position, long timeUs) { - if (position == 0) { + if (position <= id3SectionSize) { metadataParsed = false; } + long flacStreamPosition = Math.max(0, position - id3SectionSize); if (decoderJni != null) { - decoderJni.reset(position); + decoderJni.reset(flacStreamPosition); } } @@ -170,14 +229,48 @@ public final class FlacExtractor implements Extractor { } } + /** + * Peeks ID3 tag data (if present) at the beginning of the input. + * + * @return The first ID3 tag decoded into a {@link Metadata} object. May be null if ID3 tag is not + * present in the input. + */ + @Nullable + private Metadata peekId3Data(ExtractorInput input) throws IOException, InterruptedException { + input.resetPeekPosition(); + boolean disableId3Frames = (flags & FLAG_DISABLE_ID3_METADATA) != 0; + Id3Decoder.FramePredicate id3FramePredicate = + disableId3Frames ? Id3Decoder.NO_FRAMES_PREDICATE : null; + return id3Peeker.peekId3Data(input, id3FramePredicate); + } + + /** + * Peeks from the beginning of the input to see if {@link #FLAC_SIGNATURE} is present. + * + * @return Whether the input begins with {@link #FLAC_SIGNATURE}. + */ + private boolean peekFlacSignature(ExtractorInput input) throws IOException, InterruptedException { + byte[] header = new byte[FLAC_SIGNATURE.length]; + input.peekFully(header, 0, FLAC_SIGNATURE.length); + return Arrays.equals(header, FLAC_SIGNATURE); + } + + /** Skips input until we have passed the whole Id3 section. */ + private void skipFullyId3Section(ExtractorInput input) throws IOException, InterruptedException { + int bytesToSkip = Math.max(0, (int) (id3SectionSize - input.getPosition())); + input.skipFully(bytesToSkip); + } + private static final class FlacSeekMap implements SeekMap { private final long durationUs; private final FlacDecoderJni decoderJni; + private final long id3SectionSize; - public FlacSeekMap(long durationUs, FlacDecoderJni decoderJni) { + public FlacSeekMap(long durationUs, FlacDecoderJni decoderJni, long id3SectionSize) { this.durationUs = durationUs; this.decoderJni = decoderJni; + this.id3SectionSize = id3SectionSize; } @Override @@ -188,7 +281,8 @@ public final class FlacExtractor implements Extractor { @Override public SeekPoints getSeekPoints(long timeUs) { // TODO: Access the seek table via JNI to return two seek points when appropriate. - return new SeekPoints(new SeekPoint(timeUs, decoderJni.getSeekPosition(timeUs))); + return new SeekPoints( + new SeekPoint(timeUs, id3SectionSize + decoderJni.getSeekPosition(timeUs))); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java new file mode 100644 index 0000000000..8dbcfafaf2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2018 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.exoplayer2.extractor; + +import android.support.annotation.Nullable; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; + +/** + * Peeks data from the beginning of an {@link ExtractorInput} to determine if there is any ID3 tag. + */ +public final class Id3Peeker { + + private final ParsableByteArray scratch; + + public Id3Peeker() { + scratch = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + } + + /** + * Peeks ID3 data from the input and parses the first ID3 tag. + * + * @param input The {@link ExtractorInput} from which data should be peeked. + * @param id3FramePredicate Determines which ID3 frames are decoded. May be null to decode all + * frames. + * @return The first ID3 tag decoded into a {@link Metadata} object. May be null if ID3 tag is not + * present in the input. + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + @Nullable + public Metadata peekId3Data( + ExtractorInput input, @Nullable Id3Decoder.FramePredicate id3FramePredicate) + throws IOException, InterruptedException { + int peekedId3Bytes = 0; + Metadata metadata = null; + while (true) { + try { + input.peekFully(scratch.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } catch (EOFException e) { + // If input has less than ID3_HEADER_LENGTH, ignore the rest. + break; + } + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != Id3Decoder.ID3_TAG) { + // Not an ID3 tag. + break; + } + scratch.skipBytes(3); // Skip major version, minor version and flags. + int framesLength = scratch.readSynchSafeInt(); + int tagLength = Id3Decoder.ID3_HEADER_LENGTH + framesLength; + + if (metadata == null) { + byte[] id3Data = new byte[tagLength]; + System.arraycopy(scratch.data, 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); + input.peekFully(id3Data, Id3Decoder.ID3_HEADER_LENGTH, framesLength); + + metadata = new Id3Decoder(id3FramePredicate).decode(id3Data, tagLength); + } else { + input.advancePeekPosition(framesLength); + } + + peekedId3Bytes += tagLength; + } + + input.resetPeekPosition(); + input.advancePeekPosition(peekedId3Bytes); + return metadata; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index 5c56dc460a..bd786191a0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import com.google.android.exoplayer2.extractor.Id3Peeker; import com.google.android.exoplayer2.extractor.MpegAudioHeader; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; @@ -99,6 +100,7 @@ public final class Mp3Extractor implements Extractor { private final ParsableByteArray scratch; private final MpegAudioHeader synchronizedHeader; private final GaplessInfoHolder gaplessInfoHolder; + private final Id3Peeker id3Peeker; // Extractor outputs. private ExtractorOutput extractorOutput; @@ -135,6 +137,7 @@ public final class Mp3Extractor implements Extractor { synchronizedHeader = new MpegAudioHeader(); gaplessInfoHolder = new GaplessInfoHolder(); basisTimeUs = C.TIME_UNSET; + id3Peeker = new Id3Peeker(); } // Extractor implementation. @@ -181,11 +184,23 @@ public final class Mp3Extractor implements Extractor { seeker = getConstantBitrateSeeker(input); } extractorOutput.seekMap(seeker); - trackOutput.format(Format.createAudioSampleFormat(null, synchronizedHeader.mimeType, null, - Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, synchronizedHeader.channels, - synchronizedHeader.sampleRate, Format.NO_VALUE, gaplessInfoHolder.encoderDelay, - gaplessInfoHolder.encoderPadding, null, null, 0, null, - (flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata)); + trackOutput.format( + Format.createAudioSampleFormat( + /* id= */ null, + synchronizedHeader.mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + MpegAudioHeader.MAX_FRAME_SIZE_BYTES, + synchronizedHeader.channels, + synchronizedHeader.sampleRate, + /* pcmEncoding= */ Format.NO_VALUE, + gaplessInfoHolder.encoderDelay, + gaplessInfoHolder.encoderPadding, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null, + (flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata)); } return readSample(input); } @@ -242,7 +257,15 @@ public final class Mp3Extractor implements Extractor { int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES; input.resetPeekPosition(); if (input.getPosition() == 0) { - peekId3Data(input); + // We need to parse enough ID3 metadata to retrieve any gapless playback information even + // if ID3 metadata parsing is disabled. + boolean onlyDecodeGaplessInfoFrames = (flags & FLAG_DISABLE_ID3_METADATA) != 0; + Id3Decoder.FramePredicate id3FramePredicate = + onlyDecodeGaplessInfoFrames ? GaplessInfoHolder.GAPLESS_INFO_ID3_FRAME_PREDICATE : null; + metadata = id3Peeker.peekId3Data(input, id3FramePredicate); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } peekedId3Bytes = (int) input.getPeekPosition(); if (!sniffing) { input.skipFully(peekedId3Bytes); @@ -296,49 +319,6 @@ public final class Mp3Extractor implements Extractor { return true; } - /** - * Peeks ID3 data from the input, including gapless playback information. - * - * @param input The {@link ExtractorInput} from which data should be peeked. - * @throws IOException If an error occurred peeking from the input. - * @throws InterruptedException If the thread was interrupted. - */ - private void peekId3Data(ExtractorInput input) throws IOException, InterruptedException { - int peekedId3Bytes = 0; - while (true) { - input.peekFully(scratch.data, 0, Id3Decoder.ID3_HEADER_LENGTH); - scratch.setPosition(0); - if (scratch.readUnsignedInt24() != Id3Decoder.ID3_TAG) { - // Not an ID3 tag. - break; - } - scratch.skipBytes(3); // Skip major version, minor version and flags. - int framesLength = scratch.readSynchSafeInt(); - int tagLength = Id3Decoder.ID3_HEADER_LENGTH + framesLength; - - if (metadata == null) { - byte[] id3Data = new byte[tagLength]; - System.arraycopy(scratch.data, 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); - input.peekFully(id3Data, Id3Decoder.ID3_HEADER_LENGTH, framesLength); - // We need to parse enough ID3 metadata to retrieve any gapless playback information even - // if ID3 metadata parsing is disabled. - Id3Decoder.FramePredicate id3FramePredicate = (flags & FLAG_DISABLE_ID3_METADATA) != 0 - ? GaplessInfoHolder.GAPLESS_INFO_ID3_FRAME_PREDICATE : null; - metadata = new Id3Decoder(id3FramePredicate).decode(id3Data, tagLength); - if (metadata != null) { - gaplessInfoHolder.setFromMetadata(metadata); - } - } else { - input.advancePeekPosition(framesLength); - } - - peekedId3Bytes += tagLength; - } - - input.resetPeekPosition(); - input.advancePeekPosition(peekedId3Bytes); - } - /** * Consumes the next frame from the {@code input} if it contains VBRI or Xing seeking metadata, * returning a {@link Seeker} if the metadata was present and valid, or {@code null} otherwise. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index a3b88953ad..ad24bac6c4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -53,6 +53,16 @@ public final class Id3Decoder implements MetadataDecoder { } + /** A predicate that indicates no frames should be decoded. */ + public static final FramePredicate NO_FRAMES_PREDICATE = + new FramePredicate() { + + @Override + public boolean evaluate(int majorVersion, int id0, int id1, int id2, int id3) { + return false; + } + }; + private static final String TAG = "Id3Decoder"; /** diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java new file mode 100644 index 0000000000..a397f70886 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2018 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.exoplayer2.extractor; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import com.google.android.exoplayer2.metadata.id3.Id3DecoderTest; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import java.io.IOException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit test for {@link Id3Peeker}. */ +@RunWith(RobolectricTestRunner.class) +public final class Id3PeekerTest { + + @Test + public void testPeekId3Data_returnNull_ifId3TagNotPresentAtBeginningOfInput() + throws IOException, InterruptedException { + Id3Peeker id3Peeker = new Id3Peeker(); + FakeExtractorInput input = + new FakeExtractorInput.Builder() + .setData(new byte[] {1, 'I', 'D', '3', 2, 3, 4, 5, 6, 7, 8, 9, 10}) + .build(); + + Metadata metadata = id3Peeker.peekId3Data(input, /* id3FramePredicate= */ null); + assertThat(metadata).isNull(); + } + + @Test + public void testPeekId3Data_returnId3Tag_ifId3TagPresent() + throws IOException, InterruptedException { + Id3Peeker id3Peeker = new Id3Peeker(); + + byte[] rawId3 = + Id3DecoderTest.buildSingleFrameTag( + "APIC", + new byte[] { + 3, 105, 109, 97, 103, 101, 47, 106, 112, 101, 103, 0, 16, 72, 101, 108, 108, 111, 32, + 87, 111, 114, 108, 100, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 + }); + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(rawId3).build(); + + Metadata metadata = id3Peeker.peekId3Data(input, /* id3FramePredicate= */ null); + assertThat(metadata.length()).isEqualTo(1); + + ApicFrame apicFrame = (ApicFrame) metadata.get(0); + assertThat(apicFrame.mimeType).isEqualTo("image/jpeg"); + assertThat(apicFrame.pictureType).isEqualTo(16); + assertThat(apicFrame.description).isEqualTo("Hello World"); + assertThat(apicFrame.pictureData).hasLength(10); + assertThat(apicFrame.pictureData).isEqualTo(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); + } + + @Test + public void testPeekId3Data_returnId3TagAccordingToGivenPredicate_ifId3TagPresent() + throws IOException, InterruptedException { + Id3Peeker id3Peeker = new Id3Peeker(); + + byte[] rawId3 = + Id3DecoderTest.buildMultiFramesTag( + new Id3DecoderTest.FrameSpec( + "COMM", + new byte[] { + 3, 101, 110, 103, 100, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 0, 116, + 101, 120, 116, 0 + }), + new Id3DecoderTest.FrameSpec( + "APIC", + new byte[] { + 3, 105, 109, 97, 103, 101, 47, 106, 112, 101, 103, 0, 16, 72, 101, 108, 108, 111, + 32, 87, 111, 114, 108, 100, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 + })); + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(rawId3).build(); + + Metadata metadata = + id3Peeker.peekId3Data( + input, + new Id3Decoder.FramePredicate() { + @Override + public boolean evaluate(int majorVersion, int id0, int id1, int id2, int id3) { + return id0 == 'C' && id1 == 'O' && id2 == 'M' && id3 == 'M'; + } + }); + assertThat(metadata.length()).isEqualTo(1); + + CommentFrame commentFrame = (CommentFrame) metadata.get(0); + assertThat(commentFrame.language).isEqualTo("eng"); + assertThat(commentFrame.description).isEqualTo("description"); + assertThat(commentFrame.text).isEqualTo("text"); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java index 4e7ae0eec0..0b992f0981 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataDecoderException; import com.google.android.exoplayer2.util.Assertions; import java.nio.charset.Charset; +import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -32,7 +33,7 @@ import org.robolectric.RobolectricTestRunner; @RunWith(RobolectricTestRunner.class) public final class Id3DecoderTest { - private static final byte[] TAG_HEADER = new byte[] {73, 68, 51, 4, 0, 0, 0, 0, 0, 0}; + private static final byte[] TAG_HEADER = new byte[] {'I', 'D', '3', 4, 0, 0, 0, 0, 0, 0}; private static final int FRAME_HEADER_LENGTH = 10; private static final int ID3_TEXT_ENCODING_UTF_8 = 3; @@ -202,33 +203,90 @@ public final class Id3DecoderTest { assertThat(commentFrame.text).isEmpty(); } - private static byte[] buildSingleFrameTag(String frameId, byte[] frameData) { - byte[] frameIdBytes = frameId.getBytes(Charset.forName(C.UTF8_NAME)); - Assertions.checkState(frameIdBytes.length == 4); + @Test + public void testDecodeMultiFrames() throws MetadataDecoderException { + byte[] rawId3 = + buildMultiFramesTag( + new FrameSpec( + "COMM", + new byte[] { + 3, 101, 110, 103, 100, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 0, 116, + 101, 120, 116, 0 + }), + new FrameSpec( + "APIC", + new byte[] { + 3, 105, 109, 97, 103, 101, 47, 106, 112, 101, 103, 0, 16, 72, 101, 108, 108, 111, + 32, 87, 111, 114, 108, 100, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 + })); + Id3Decoder decoder = new Id3Decoder(); + Metadata metadata = decoder.decode(rawId3, rawId3.length); + assertThat(metadata.length()).isEqualTo(2); + CommentFrame commentFrame = (CommentFrame) metadata.get(0); + ApicFrame apicFrame = (ApicFrame) metadata.get(1); - byte[] tagData = new byte[TAG_HEADER.length + FRAME_HEADER_LENGTH + frameData.length]; - System.arraycopy(TAG_HEADER, 0, tagData, 0, TAG_HEADER.length); + assertThat(commentFrame.language).isEqualTo("eng"); + assertThat(commentFrame.description).isEqualTo("description"); + assertThat(commentFrame.text).isEqualTo("text"); + + assertThat(apicFrame.mimeType).isEqualTo("image/jpeg"); + assertThat(apicFrame.pictureType).isEqualTo(16); + assertThat(apicFrame.description).isEqualTo("Hello World"); + assertThat(apicFrame.pictureData).hasLength(10); + assertThat(apicFrame.pictureData).isEqualTo(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}); + } + + public static byte[] buildSingleFrameTag(String frameId, byte[] frameData) { + return buildMultiFramesTag(new FrameSpec(frameId, frameData)); + } + + public static byte[] buildMultiFramesTag(FrameSpec... frames) { + int totalLength = TAG_HEADER.length; + for (FrameSpec frame : frames) { + byte[] frameData = frame.frameData; + totalLength += FRAME_HEADER_LENGTH + frameData.length; + } + byte[] tagData = Arrays.copyOf(TAG_HEADER, totalLength); // Fill in the size part of the tag header. int offset = TAG_HEADER.length - 4; - int tagSize = frameData.length + FRAME_HEADER_LENGTH; + int tagSize = totalLength - TAG_HEADER.length; tagData[offset++] = (byte) ((tagSize >> 21) & 0x7F); tagData[offset++] = (byte) ((tagSize >> 14) & 0x7F); tagData[offset++] = (byte) ((tagSize >> 7) & 0x7F); tagData[offset++] = (byte) (tagSize & 0x7F); - // Fill in the frame header. - tagData[offset++] = frameIdBytes[0]; - tagData[offset++] = frameIdBytes[1]; - tagData[offset++] = frameIdBytes[2]; - tagData[offset++] = frameIdBytes[3]; - tagData[offset++] = (byte) ((frameData.length >> 24) & 0xFF); - tagData[offset++] = (byte) ((frameData.length >> 16) & 0xFF); - tagData[offset++] = (byte) ((frameData.length >> 8) & 0xFF); - tagData[offset++] = (byte) (frameData.length & 0xFF); - offset += 2; // Frame flags set to 0 - // Fill in the frame data. - System.arraycopy(frameData, 0, tagData, offset, frameData.length); + for (FrameSpec frame : frames) { + byte[] frameData = frame.frameData; + String frameId = frame.frameId; + byte[] frameIdBytes = frameId.getBytes(Charset.forName(C.UTF8_NAME)); + Assertions.checkState(frameIdBytes.length == 4); + + // Fill in the frame header. + tagData[offset++] = frameIdBytes[0]; + tagData[offset++] = frameIdBytes[1]; + tagData[offset++] = frameIdBytes[2]; + tagData[offset++] = frameIdBytes[3]; + tagData[offset++] = (byte) ((frameData.length >> 24) & 0xFF); + tagData[offset++] = (byte) ((frameData.length >> 16) & 0xFF); + tagData[offset++] = (byte) ((frameData.length >> 8) & 0xFF); + tagData[offset++] = (byte) (frameData.length & 0xFF); + offset += 2; // Frame flags set to 0 + + // Fill in the frame data. + System.arraycopy(frameData, 0, tagData, offset, frameData.length); + offset += frameData.length; + } return tagData; } + /** Specify an ID3 frame. */ + public static final class FrameSpec { + public final String frameId; + public final byte[] frameData; + + public FrameSpec(String frameId, byte[] frameData) { + this.frameId = frameId; + this.frameData = frameData; + } + } }