Commit 986095a4 by hoangtc Committed by Oliver Woodman

Support FLAC files with ID3 headers.

Support parsing ID3 tags at the beginning of FLAC files, even though FLAC spec
does not require this.

GitHub: #4055.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=192127929
parent 6dc6f79f
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
* Allow setting tags for all media sources in their factories. The tag of the * Allow setting tags for all media sources in their factories. The tag of the
current window can be retrieved with `ExoPlayer.getCurrentTag`. current window can be retrieved with `ExoPlayer.getCurrentTag`.
* Audio: * 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`. * Factor out `AudioTrack` position tracking from `DefaultAudioSink`.
* Fix an issue where the playback position would pause just after playback * Fix an issue where the playback position would pause just after playback
begins, and poll the audio timestamp less frequently once it starts begins, and poll the audio timestamp less frequently once it starts
......
...@@ -31,6 +31,7 @@ android { ...@@ -31,6 +31,7 @@ android {
} }
dependencies { dependencies {
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-core')
androidTestImplementation project(modulePrefix + 'testutils') androidTestImplementation project(modulePrefix + 'testutils')
} }
......
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
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
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
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
...@@ -33,7 +33,7 @@ public class FlacExtractorTest extends InstrumentationTestCase { ...@@ -33,7 +33,7 @@ public class FlacExtractorTest extends InstrumentationTestCase {
} }
} }
public void testSample() throws Exception { public void testExtractFlacSample() throws Exception {
ExtractorAsserts.assertBehavior( ExtractorAsserts.assertBehavior(
new ExtractorFactory() { new ExtractorFactory() {
@Override @Override
...@@ -44,4 +44,16 @@ public class FlacExtractorTest extends InstrumentationTestCase { ...@@ -44,4 +44,16 @@ public class FlacExtractorTest extends InstrumentationTestCase {
"bear.flac", "bear.flac",
getInstrumentation().getContext()); getInstrumentation().getContext());
} }
public void testExtractFlacSampleWithId3Header() throws Exception {
ExtractorAsserts.assertBehavior(
new ExtractorFactory() {
@Override
public Extractor create() {
return new FlacExtractor();
}
},
"bear_with_id3.flac",
getInstrumentation().getContext());
}
} }
...@@ -17,20 +17,27 @@ package com.google.android.exoplayer2.ext.flac; ...@@ -17,20 +17,27 @@ package com.google.android.exoplayer2.ext.flac;
import static com.google.android.exoplayer2.util.Util.getPcmEncoding; 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.C;
import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.Extractor;
import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorOutput;
import com.google.android.exoplayer2.extractor.ExtractorsFactory; 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.PositionHolder;
import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekMap;
import com.google.android.exoplayer2.extractor.SeekPoint; import com.google.android.exoplayer2.extractor.SeekPoint;
import com.google.android.exoplayer2.extractor.TrackOutput; 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.FlacStreamInfo;
import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.ParsableByteArray;
import java.io.IOException; import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.Arrays; import java.util.Arrays;
...@@ -51,22 +58,57 @@ public final class FlacExtractor implements Extractor { ...@@ -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 * FLAC signature: first 4 is the signature word, second 4 is the sizeof STREAMINFO. 0x22 is the
* mandatory STREAMINFO. * mandatory STREAMINFO.
*/ */
private static final byte[] FLAC_SIGNATURE = {'f', 'L', 'a', 'C', 0, 0, 0, 0x22}; private static final byte[] FLAC_SIGNATURE = {'f', 'L', 'a', 'C', 0, 0, 0, 0x22};
private ExtractorOutput extractorOutput; private final Id3Peeker id3Peeker;
private TrackOutput trackOutput; private final @Flags int flags;
private FlacDecoderJni decoderJni; private FlacDecoderJni decoderJni;
private boolean metadataParsed; private ExtractorOutput extractorOutput;
private TrackOutput trackOutput;
private ParsableByteArray outputBuffer; private ParsableByteArray outputBuffer;
private ByteBuffer outputByteBuffer; 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 @Override
public void init(ExtractorOutput output) { public void init(ExtractorOutput output) {
extractorOutput = output; extractorOutput = output;
...@@ -81,14 +123,27 @@ public final class FlacExtractor implements Extractor { ...@@ -81,14 +123,27 @@ public final class FlacExtractor implements Extractor {
@Override @Override
public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { public boolean sniff(ExtractorInput input) throws IOException, InterruptedException {
byte[] header = new byte[FLAC_SIGNATURE.length]; if (input.getPosition() == 0) {
input.peekFully(header, 0, FLAC_SIGNATURE.length); id3Metadata = peekId3Data(input);
return Arrays.equals(header, FLAC_SIGNATURE); 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 @Override
public int read(final ExtractorInput input, PositionHolder seekPosition) public int read(final ExtractorInput input, PositionHolder seekPosition)
throws IOException, InterruptedException { throws IOException, InterruptedException {
if (input.getPosition() == 0) {
id3Metadata = peekId3Data(input);
id3SectionSize = input.getPeekPosition();
}
skipFullyId3Section(input);
decoderJni.setData(input); decoderJni.setData(input);
if (!metadataParsed) { if (!metadataParsed) {
...@@ -100,7 +155,7 @@ public final class FlacExtractor implements Extractor { ...@@ -100,7 +155,7 @@ public final class FlacExtractor implements Extractor {
} }
} catch (IOException e) { } catch (IOException e) {
decoderJni.reset(0); decoderJni.reset(0);
input.setRetryPosition(0, e); input.setRetryPosition(id3SectionSize, e);
throw e; // never executes throw e; // never executes
} }
metadataParsed = true; metadataParsed = true;
...@@ -108,22 +163,25 @@ public final class FlacExtractor implements Extractor { ...@@ -108,22 +163,25 @@ public final class FlacExtractor implements Extractor {
boolean isSeekable = decoderJni.getSeekPosition(0) != -1; boolean isSeekable = decoderJni.getSeekPosition(0) != -1;
extractorOutput.seekMap( extractorOutput.seekMap(
isSeekable isSeekable
? new FlacSeekMap(streamInfo.durationUs(), decoderJni) ? new FlacSeekMap(streamInfo.durationUs(), decoderJni, id3SectionSize)
: new SeekMap.Unseekable(streamInfo.durationUs(), 0)); : new SeekMap.Unseekable(streamInfo.durationUs(), 0));
Format mediaFormat = Format mediaFormat =
Format.createAudioSampleFormat( Format.createAudioSampleFormat(
null, /* id= */ null,
MimeTypes.AUDIO_RAW, MimeTypes.AUDIO_RAW,
null, /* codecs= */ null,
streamInfo.bitRate(), streamInfo.bitRate(),
streamInfo.maxDecodedFrameSize(), streamInfo.maxDecodedFrameSize(),
streamInfo.channels, streamInfo.channels,
streamInfo.sampleRate, streamInfo.sampleRate,
getPcmEncoding(streamInfo.bitsPerSample), getPcmEncoding(streamInfo.bitsPerSample),
null, /* encoderDelay= */ 0,
null, /* encoderPadding= */ 0,
0, /* initializationData= */ null,
null); /* drmInitData= */ null,
/* selectionFlags= */ 0,
/* language= */ null,
(flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : id3Metadata);
trackOutput.format(mediaFormat); trackOutput.format(mediaFormat);
outputBuffer = new ParsableByteArray(streamInfo.maxDecodedFrameSize()); outputBuffer = new ParsableByteArray(streamInfo.maxDecodedFrameSize());
...@@ -138,7 +196,7 @@ public final class FlacExtractor implements Extractor { ...@@ -138,7 +196,7 @@ public final class FlacExtractor implements Extractor {
} catch (IOException e) { } catch (IOException e) {
if (lastDecodePosition >= 0) { if (lastDecodePosition >= 0) {
decoderJni.reset(lastDecodePosition); decoderJni.reset(lastDecodePosition);
input.setRetryPosition(lastDecodePosition, e); input.setRetryPosition(id3SectionSize + lastDecodePosition, e);
} }
throw e; throw e;
} }
...@@ -154,11 +212,12 @@ public final class FlacExtractor implements Extractor { ...@@ -154,11 +212,12 @@ public final class FlacExtractor implements Extractor {
@Override @Override
public void seek(long position, long timeUs) { public void seek(long position, long timeUs) {
if (position == 0) { if (position <= id3SectionSize) {
metadataParsed = false; metadataParsed = false;
} }
long flacStreamPosition = Math.max(0, position - id3SectionSize);
if (decoderJni != null) { if (decoderJni != null) {
decoderJni.reset(position); decoderJni.reset(flacStreamPosition);
} }
} }
...@@ -170,14 +229,48 @@ public final class FlacExtractor implements Extractor { ...@@ -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 static final class FlacSeekMap implements SeekMap {
private final long durationUs; private final long durationUs;
private final FlacDecoderJni decoderJni; 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.durationUs = durationUs;
this.decoderJni = decoderJni; this.decoderJni = decoderJni;
this.id3SectionSize = id3SectionSize;
} }
@Override @Override
...@@ -188,7 +281,8 @@ public final class FlacExtractor implements Extractor { ...@@ -188,7 +281,8 @@ public final class FlacExtractor implements Extractor {
@Override @Override
public SeekPoints getSeekPoints(long timeUs) { public SeekPoints getSeekPoints(long timeUs) {
// TODO: Access the seek table via JNI to return two seek points when appropriate. // 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 @Override
......
/*
* 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;
}
}
...@@ -24,6 +24,7 @@ import com.google.android.exoplayer2.extractor.ExtractorInput; ...@@ -24,6 +24,7 @@ import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorOutput;
import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.ExtractorsFactory;
import com.google.android.exoplayer2.extractor.GaplessInfoHolder; 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.MpegAudioHeader;
import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.PositionHolder;
import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekMap;
...@@ -99,6 +100,7 @@ public final class Mp3Extractor implements Extractor { ...@@ -99,6 +100,7 @@ public final class Mp3Extractor implements Extractor {
private final ParsableByteArray scratch; private final ParsableByteArray scratch;
private final MpegAudioHeader synchronizedHeader; private final MpegAudioHeader synchronizedHeader;
private final GaplessInfoHolder gaplessInfoHolder; private final GaplessInfoHolder gaplessInfoHolder;
private final Id3Peeker id3Peeker;
// Extractor outputs. // Extractor outputs.
private ExtractorOutput extractorOutput; private ExtractorOutput extractorOutput;
...@@ -135,6 +137,7 @@ public final class Mp3Extractor implements Extractor { ...@@ -135,6 +137,7 @@ public final class Mp3Extractor implements Extractor {
synchronizedHeader = new MpegAudioHeader(); synchronizedHeader = new MpegAudioHeader();
gaplessInfoHolder = new GaplessInfoHolder(); gaplessInfoHolder = new GaplessInfoHolder();
basisTimeUs = C.TIME_UNSET; basisTimeUs = C.TIME_UNSET;
id3Peeker = new Id3Peeker();
} }
// Extractor implementation. // Extractor implementation.
...@@ -181,11 +184,23 @@ public final class Mp3Extractor implements Extractor { ...@@ -181,11 +184,23 @@ public final class Mp3Extractor implements Extractor {
seeker = getConstantBitrateSeeker(input); seeker = getConstantBitrateSeeker(input);
} }
extractorOutput.seekMap(seeker); extractorOutput.seekMap(seeker);
trackOutput.format(Format.createAudioSampleFormat(null, synchronizedHeader.mimeType, null, trackOutput.format(
Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, synchronizedHeader.channels, Format.createAudioSampleFormat(
synchronizedHeader.sampleRate, Format.NO_VALUE, gaplessInfoHolder.encoderDelay, /* id= */ null,
gaplessInfoHolder.encoderPadding, null, null, 0, null, synchronizedHeader.mimeType,
(flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata)); /* 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); return readSample(input);
} }
...@@ -242,7 +257,15 @@ public final class Mp3Extractor implements Extractor { ...@@ -242,7 +257,15 @@ public final class Mp3Extractor implements Extractor {
int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES; int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES;
input.resetPeekPosition(); input.resetPeekPosition();
if (input.getPosition() == 0) { 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(); peekedId3Bytes = (int) input.getPeekPosition();
if (!sniffing) { if (!sniffing) {
input.skipFully(peekedId3Bytes); input.skipFully(peekedId3Bytes);
...@@ -297,49 +320,6 @@ public final class Mp3Extractor implements Extractor { ...@@ -297,49 +320,6 @@ public final class Mp3Extractor implements Extractor {
} }
/** /**
* 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, * 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. * returning a {@link Seeker} if the metadata was present and valid, or {@code null} otherwise.
* After this method returns, the input position is the start of the first frame of audio. * After this method returns, the input position is the start of the first frame of audio.
......
...@@ -53,6 +53,16 @@ public final class Id3Decoder implements MetadataDecoder { ...@@ -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"; private static final String TAG = "Id3Decoder";
/** /**
......
/*
* 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");
}
}
...@@ -22,6 +22,7 @@ import com.google.android.exoplayer2.metadata.Metadata; ...@@ -22,6 +22,7 @@ import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.MetadataDecoderException; import com.google.android.exoplayer2.metadata.MetadataDecoderException;
import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Assertions;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.Arrays;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
...@@ -32,7 +33,7 @@ import org.robolectric.RobolectricTestRunner; ...@@ -32,7 +33,7 @@ import org.robolectric.RobolectricTestRunner;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
public final class Id3DecoderTest { 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 FRAME_HEADER_LENGTH = 10;
private static final int ID3_TEXT_ENCODING_UTF_8 = 3; private static final int ID3_TEXT_ENCODING_UTF_8 = 3;
...@@ -202,33 +203,90 @@ public final class Id3DecoderTest { ...@@ -202,33 +203,90 @@ public final class Id3DecoderTest {
assertThat(commentFrame.text).isEmpty(); assertThat(commentFrame.text).isEmpty();
} }
private static byte[] buildSingleFrameTag(String frameId, byte[] frameData) { @Test
byte[] frameIdBytes = frameId.getBytes(Charset.forName(C.UTF8_NAME)); public void testDecodeMultiFrames() throws MetadataDecoderException {
Assertions.checkState(frameIdBytes.length == 4); 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);
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));
}
byte[] tagData = new byte[TAG_HEADER.length + FRAME_HEADER_LENGTH + frameData.length]; public static byte[] buildMultiFramesTag(FrameSpec... frames) {
System.arraycopy(TAG_HEADER, 0, tagData, 0, TAG_HEADER.length); 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. // Fill in the size part of the tag header.
int offset = TAG_HEADER.length - 4; 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 >> 21) & 0x7F);
tagData[offset++] = (byte) ((tagSize >> 14) & 0x7F); tagData[offset++] = (byte) ((tagSize >> 14) & 0x7F);
tagData[offset++] = (byte) ((tagSize >> 7) & 0x7F); tagData[offset++] = (byte) ((tagSize >> 7) & 0x7F);
tagData[offset++] = (byte) (tagSize & 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; 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;
}
}
} }
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