diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3')
6 files changed, 1037 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java new file mode 100644 index 0000000000..1a442110e3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ConstantBitrateSeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; + +/** + * MP3 seeker that doesn't rely on metadata and seeks assuming the source has a constant bitrate. + */ +/* package */ final class ConstantBitrateSeeker extends ConstantBitrateSeekMap implements Seeker { + + /** + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param firstFramePosition The position of the first frame in the stream. + * @param mpegAudioHeader The MPEG audio header associated with the first frame. + */ + public ConstantBitrateSeeker( + long inputLength, long firstFramePosition, MpegAudioHeader mpegAudioHeader) { + super(inputLength, firstFramePosition, mpegAudioHeader.bitrate, mpegAudioHeader.frameSize); + } + + @Override + public long getTimeUs(long position) { + return getTimeUsAtPosition(position); + } + + @Override + public long getDataEndPosition() { + return C.POSITION_UNSET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java new file mode 100644 index 0000000000..662ded4ec3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java @@ -0,0 +1,125 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.MlltFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** MP3 seeker that uses metadata from an {@link MlltFrame}. */ +/* package */ final class MlltSeeker implements Seeker { + + /** + * Returns an {@link MlltSeeker} for seeking in the stream. + * + * @param firstFramePosition The position of the start of the first frame in the stream. + * @param mlltFrame The MLLT frame with seeking metadata. + * @return An {@link MlltSeeker} for seeking in the stream. + */ + public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame) { + int referenceCount = mlltFrame.bytesDeviations.length; + long[] referencePositions = new long[1 + referenceCount]; + long[] referenceTimesMs = new long[1 + referenceCount]; + referencePositions[0] = firstFramePosition; + referenceTimesMs[0] = 0; + long position = firstFramePosition; + long timeMs = 0; + for (int i = 1; i <= referenceCount; i++) { + position += mlltFrame.bytesBetweenReference + mlltFrame.bytesDeviations[i - 1]; + timeMs += mlltFrame.millisecondsBetweenReference + mlltFrame.millisecondsDeviations[i - 1]; + referencePositions[i] = position; + referenceTimesMs[i] = timeMs; + } + return new MlltSeeker(referencePositions, referenceTimesMs); + } + + private final long[] referencePositions; + private final long[] referenceTimesMs; + private final long durationUs; + + private MlltSeeker(long[] referencePositions, long[] referenceTimesMs) { + this.referencePositions = referencePositions; + this.referenceTimesMs = referenceTimesMs; + // Use the last reference point as the duration, as extrapolating variable bitrate at the end of + // the stream may give a large error. + durationUs = C.msToUs(referenceTimesMs[referenceTimesMs.length - 1]); + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + timeUs = Util.constrainValue(timeUs, 0, durationUs); + Pair<Long, Long> timeMsAndPosition = + linearlyInterpolate(C.usToMs(timeUs), referenceTimesMs, referencePositions); + timeUs = C.msToUs(timeMsAndPosition.first); + long position = timeMsAndPosition.second; + return new SeekPoints(new SeekPoint(timeUs, position)); + } + + @Override + public long getTimeUs(long position) { + Pair<Long, Long> positionAndTimeMs = + linearlyInterpolate(position, referencePositions, referenceTimesMs); + return C.msToUs(positionAndTimeMs.second); + } + + @Override + public long getDurationUs() { + return durationUs; + } + + /** + * Given a set of reference points as coordinates in {@code xReferences} and {@code yReferences} + * and an x-axis value, linearly interpolates between corresponding reference points to give a + * y-axis value. + * + * @param x The x-axis value for which a y-axis value is needed. + * @param xReferences x coordinates of reference points. + * @param yReferences y coordinates of reference points. + * @return The linearly interpolated y-axis value. + */ + private static Pair<Long, Long> linearlyInterpolate( + long x, long[] xReferences, long[] yReferences) { + int previousReferenceIndex = + Util.binarySearchFloor(xReferences, x, /* inclusive= */ true, /* stayInBounds= */ true); + long xPreviousReference = xReferences[previousReferenceIndex]; + long yPreviousReference = yReferences[previousReferenceIndex]; + int nextReferenceIndex = previousReferenceIndex + 1; + if (nextReferenceIndex == xReferences.length) { + return Pair.create(xPreviousReference, yPreviousReference); + } else { + long xNextReference = xReferences[nextReferenceIndex]; + long yNextReference = yReferences[nextReferenceIndex]; + double proportion = + xNextReference == xPreviousReference + ? 0.0 + : ((double) x - xPreviousReference) / (xNextReference - xPreviousReference); + long y = (long) (proportion * (yNextReference - yPreviousReference)) + yPreviousReference; + return Pair.create(x, y); + } + } + + @Override + public long getDataEndPosition() { + return C.POSITION_UNSET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java new file mode 100644 index 0000000000..2829a1e519 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Id3Peeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Seeker.UnseekableSeeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.FramePredicate; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.MlltFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Extracts data from the MP3 container format. + */ +public final class Mp3Extractor implements Extractor { + + /** Factory for {@link Mp3Extractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new Mp3Extractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag values are {@link + * #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING} and {@link #FLAG_DISABLE_ID3_METADATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_ENABLE_CONSTANT_BITRATE_SEEKING, FLAG_DISABLE_ID3_METADATA}) + public @interface Flags {} + /** + * Flag to force enable seeking using a constant bitrate assumption in cases where seeking would + * otherwise not be possible. + */ + public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING = 1; + /** + * 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 = 2; + + /** Predicate that matches ID3 frames containing only required gapless/seeking metadata. */ + private static final FramePredicate REQUIRED_ID3_FRAME_PREDICATE = + (majorVersion, id0, id1, id2, id3) -> + ((id0 == 'C' && id1 == 'O' && id2 == 'M' && (id3 == 'M' || majorVersion == 2)) + || (id0 == 'M' && id1 == 'L' && id2 == 'L' && (id3 == 'T' || majorVersion == 2))); + + /** + * The maximum number of bytes to search when synchronizing, before giving up. + */ + private static final int MAX_SYNC_BYTES = 128 * 1024; + /** + * The maximum number of bytes to peek when sniffing, excluding the ID3 header, before giving up. + */ + private static final int MAX_SNIFF_BYTES = 16 * 1024; + /** + * Maximum length of data read into {@link #scratch}. + */ + private static final int SCRATCH_LENGTH = 10; + + /** + * Mask that includes the audio header values that must match between frames. + */ + private static final int MPEG_AUDIO_HEADER_MASK = 0xFFFE0C00; + + private static final int SEEK_HEADER_XING = 0x58696e67; + private static final int SEEK_HEADER_INFO = 0x496e666f; + private static final int SEEK_HEADER_VBRI = 0x56425249; + private static final int SEEK_HEADER_UNSET = 0; + + @Flags private final int flags; + private final long forcedFirstSampleTimestampUs; + private final ParsableByteArray scratch; + private final MpegAudioHeader synchronizedHeader; + private final GaplessInfoHolder gaplessInfoHolder; + private final Id3Peeker id3Peeker; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; + + private int synchronizedHeaderData; + + private Metadata metadata; + @Nullable private Seeker seeker; + private boolean disableSeeking; + private long basisTimeUs; + private long samplesRead; + private long firstSamplePosition; + private int sampleBytesRemaining; + + public Mp3Extractor() { + this(0); + } + + /** + * @param flags Flags that control the extractor's behavior. + */ + public Mp3Extractor(@Flags int flags) { + this(flags, C.TIME_UNSET); + } + + /** + * @param flags Flags that control the extractor's behavior. + * @param forcedFirstSampleTimestampUs A timestamp to force for the first sample, or + * {@link C#TIME_UNSET} if forcing is not required. + */ + public Mp3Extractor(@Flags int flags, long forcedFirstSampleTimestampUs) { + this.flags = flags; + this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs; + scratch = new ParsableByteArray(SCRATCH_LENGTH); + synchronizedHeader = new MpegAudioHeader(); + gaplessInfoHolder = new GaplessInfoHolder(); + basisTimeUs = C.TIME_UNSET; + id3Peeker = new Id3Peeker(); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return synchronize(input, true); + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + trackOutput = extractorOutput.track(0, C.TRACK_TYPE_AUDIO); + extractorOutput.endTracks(); + } + + @Override + public void seek(long position, long timeUs) { + synchronizedHeaderData = 0; + basisTimeUs = C.TIME_UNSET; + samplesRead = 0; + sampleBytesRemaining = 0; + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + if (synchronizedHeaderData == 0) { + try { + synchronize(input, false); + } catch (EOFException e) { + return RESULT_END_OF_INPUT; + } + } + if (seeker == null) { + // Read past any seek frame and set the seeker based on metadata or a seek frame. Metadata + // takes priority as it can provide greater precision. + Seeker seekFrameSeeker = maybeReadSeekFrame(input); + Seeker metadataSeeker = maybeHandleSeekMetadata(metadata, input.getPosition()); + + if (disableSeeking) { + seeker = new UnseekableSeeker(); + } else { + if (metadataSeeker != null) { + seeker = metadataSeeker; + } else if (seekFrameSeeker != null) { + seeker = seekFrameSeeker; + } + if (seeker == null + || (!seeker.isSeekable() && (flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) != 0)) { + seeker = getConstantBitrateSeeker(input); + } + } + extractorOutput.seekMap(seeker); + 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)); + firstSamplePosition = input.getPosition(); + } else if (firstSamplePosition != 0) { + long inputPosition = input.getPosition(); + if (inputPosition < firstSamplePosition) { + // Skip past the seek frame. + input.skipFully((int) (firstSamplePosition - inputPosition)); + } + } + return readSample(input); + } + + /** + * Disables the extractor from being able to seek through the media. + * + * <p>Please note that this needs to be called before {@link #read}. + */ + public void disableSeeking() { + disableSeeking = true; + } + + // Internal methods. + + private int readSample(ExtractorInput extractorInput) throws IOException, InterruptedException { + if (sampleBytesRemaining == 0) { + extractorInput.resetPeekPosition(); + if (peekEndOfStreamOrHeader(extractorInput)) { + return RESULT_END_OF_INPUT; + } + scratch.setPosition(0); + int sampleHeaderData = scratch.readInt(); + if (!headersMatch(sampleHeaderData, synchronizedHeaderData) + || MpegAudioHeader.getFrameSize(sampleHeaderData) == C.LENGTH_UNSET) { + // We have lost synchronization, so attempt to resynchronize starting at the next byte. + extractorInput.skipFully(1); + synchronizedHeaderData = 0; + return RESULT_CONTINUE; + } + MpegAudioHeader.populateHeader(sampleHeaderData, synchronizedHeader); + if (basisTimeUs == C.TIME_UNSET) { + basisTimeUs = seeker.getTimeUs(extractorInput.getPosition()); + if (forcedFirstSampleTimestampUs != C.TIME_UNSET) { + long embeddedFirstSampleTimestampUs = seeker.getTimeUs(0); + basisTimeUs += forcedFirstSampleTimestampUs - embeddedFirstSampleTimestampUs; + } + } + sampleBytesRemaining = synchronizedHeader.frameSize; + } + int bytesAppended = trackOutput.sampleData(extractorInput, sampleBytesRemaining, true); + if (bytesAppended == C.RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + sampleBytesRemaining -= bytesAppended; + if (sampleBytesRemaining > 0) { + return RESULT_CONTINUE; + } + long timeUs = basisTimeUs + (samplesRead * C.MICROS_PER_SECOND / synchronizedHeader.sampleRate); + trackOutput.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, synchronizedHeader.frameSize, 0, + null); + samplesRead += synchronizedHeader.samplesPerFrame; + sampleBytesRemaining = 0; + return RESULT_CONTINUE; + } + + private boolean synchronize(ExtractorInput input, boolean sniffing) + throws IOException, InterruptedException { + int validFrameCount = 0; + int candidateSynchronizedHeaderData = 0; + int peekedId3Bytes = 0; + int searchedBytes = 0; + int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES; + input.resetPeekPosition(); + if (input.getPosition() == 0) { + // We need to parse enough ID3 metadata to retrieve any gapless/seeking playback information + // even if ID3 metadata parsing is disabled. + boolean parseAllId3Frames = (flags & FLAG_DISABLE_ID3_METADATA) == 0; + Id3Decoder.FramePredicate id3FramePredicate = + parseAllId3Frames ? null : REQUIRED_ID3_FRAME_PREDICATE; + metadata = id3Peeker.peekId3Data(input, id3FramePredicate); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } + peekedId3Bytes = (int) input.getPeekPosition(); + if (!sniffing) { + input.skipFully(peekedId3Bytes); + } + } + while (true) { + if (peekEndOfStreamOrHeader(input)) { + if (validFrameCount > 0) { + // We reached the end of the stream but found at least one valid frame. + break; + } + throw new EOFException(); + } + scratch.setPosition(0); + int headerData = scratch.readInt(); + int frameSize; + if ((candidateSynchronizedHeaderData != 0 + && !headersMatch(headerData, candidateSynchronizedHeaderData)) + || (frameSize = MpegAudioHeader.getFrameSize(headerData)) == C.LENGTH_UNSET) { + // The header doesn't match the candidate header or is invalid. Try the next byte offset. + if (searchedBytes++ == searchLimitBytes) { + if (!sniffing) { + throw new ParserException("Searched too many bytes."); + } + return false; + } + validFrameCount = 0; + candidateSynchronizedHeaderData = 0; + if (sniffing) { + input.resetPeekPosition(); + input.advancePeekPosition(peekedId3Bytes + searchedBytes); + } else { + input.skipFully(1); + } + } else { + // The header matches the candidate header and/or is valid. + validFrameCount++; + if (validFrameCount == 1) { + MpegAudioHeader.populateHeader(headerData, synchronizedHeader); + candidateSynchronizedHeaderData = headerData; + } else if (validFrameCount == 4) { + break; + } + input.advancePeekPosition(frameSize - 4); + } + } + // Prepare to read the synchronized frame. + if (sniffing) { + input.skipFully(peekedId3Bytes + searchedBytes); + } else { + input.resetPeekPosition(); + } + synchronizedHeaderData = candidateSynchronizedHeaderData; + return true; + } + + /** + * Returns whether the extractor input is peeking the end of the stream. If {@code false}, + * populates the scratch buffer with the next four bytes. + */ + private boolean peekEndOfStreamOrHeader(ExtractorInput extractorInput) + throws IOException, InterruptedException { + if (seeker != null) { + long dataEndPosition = seeker.getDataEndPosition(); + if (dataEndPosition != C.POSITION_UNSET + && extractorInput.getPeekPosition() > dataEndPosition - 4) { + return true; + } + } + try { + return !extractorInput.peekFully( + scratch.data, /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true); + } catch (EOFException e) { + return true; + } + } + + /** + * 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. + * After this method returns, the input position is the start of the first frame of audio. + * + * @param input The {@link ExtractorInput} from which to read. + * @return A {@link Seeker} if seeking metadata was present and valid, or {@code null} otherwise. + * @throws IOException Thrown if there was an error reading from the stream. Not expected if the + * next two frames were already peeked during synchronization. + * @throws InterruptedException Thrown if reading from the stream was interrupted. Not expected if + * the next two frames were already peeked during synchronization. + */ + private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException, InterruptedException { + ParsableByteArray frame = new ParsableByteArray(synchronizedHeader.frameSize); + input.peekFully(frame.data, 0, synchronizedHeader.frameSize); + int xingBase = (synchronizedHeader.version & 1) != 0 + ? (synchronizedHeader.channels != 1 ? 36 : 21) // MPEG 1 + : (synchronizedHeader.channels != 1 ? 21 : 13); // MPEG 2 or 2.5 + int seekHeader = getSeekFrameHeader(frame, xingBase); + Seeker seeker; + if (seekHeader == SEEK_HEADER_XING || seekHeader == SEEK_HEADER_INFO) { + seeker = XingSeeker.create(input.getLength(), input.getPosition(), synchronizedHeader, frame); + if (seeker != null && !gaplessInfoHolder.hasGaplessInfo()) { + // If there is a Xing header, read gapless playback metadata at a fixed offset. + input.resetPeekPosition(); + input.advancePeekPosition(xingBase + 141); + input.peekFully(scratch.data, 0, 3); + scratch.setPosition(0); + gaplessInfoHolder.setFromXingHeaderValue(scratch.readUnsignedInt24()); + } + input.skipFully(synchronizedHeader.frameSize); + if (seeker != null && !seeker.isSeekable() && seekHeader == SEEK_HEADER_INFO) { + // Fall back to constant bitrate seeking for Info headers missing a table of contents. + return getConstantBitrateSeeker(input); + } + } else if (seekHeader == SEEK_HEADER_VBRI) { + seeker = VbriSeeker.create(input.getLength(), input.getPosition(), synchronizedHeader, frame); + input.skipFully(synchronizedHeader.frameSize); + } else { // seekerHeader == SEEK_HEADER_UNSET + // This frame doesn't contain seeking information, so reset the peek position. + seeker = null; + input.resetPeekPosition(); + } + return seeker; + } + + /** + * Peeks the next frame and returns a {@link ConstantBitrateSeeker} based on its bitrate. + */ + private Seeker getConstantBitrateSeeker(ExtractorInput input) + throws IOException, InterruptedException { + input.peekFully(scratch.data, 0, 4); + scratch.setPosition(0); + MpegAudioHeader.populateHeader(scratch.readInt(), synchronizedHeader); + return new ConstantBitrateSeeker(input.getLength(), input.getPosition(), synchronizedHeader); + } + + /** + * Returns whether the headers match in those bits masked by {@link #MPEG_AUDIO_HEADER_MASK}. + */ + private static boolean headersMatch(int headerA, long headerB) { + return (headerA & MPEG_AUDIO_HEADER_MASK) == (headerB & MPEG_AUDIO_HEADER_MASK); + } + + /** + * Returns {@link #SEEK_HEADER_XING}, {@link #SEEK_HEADER_INFO} or {@link #SEEK_HEADER_VBRI} if + * the provided {@code frame} may have seeking metadata, or {@link #SEEK_HEADER_UNSET} otherwise. + * If seeking metadata is present, {@code frame}'s position is advanced past the header. + */ + private static int getSeekFrameHeader(ParsableByteArray frame, int xingBase) { + if (frame.limit() >= xingBase + 4) { + frame.setPosition(xingBase); + int headerData = frame.readInt(); + if (headerData == SEEK_HEADER_XING || headerData == SEEK_HEADER_INFO) { + return headerData; + } + } + if (frame.limit() >= 40) { + frame.setPosition(36); // MPEG audio header (4 bytes) + 32 bytes. + if (frame.readInt() == SEEK_HEADER_VBRI) { + return SEEK_HEADER_VBRI; + } + } + return SEEK_HEADER_UNSET; + } + + @Nullable + private static MlltSeeker maybeHandleSeekMetadata(Metadata metadata, long firstFramePosition) { + if (metadata != null) { + int length = metadata.length(); + for (int i = 0; i < length; i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof MlltFrame) { + return MlltSeeker.create(firstFramePosition, (MlltFrame) entry); + } + } + } + return null; + } + + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java new file mode 100644 index 0000000000..da0306cc60 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2019 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; + +/** + * {@link SeekMap} that provides the end position of audio data and also allows mapping from + * position (byte offset) back to time, which can be used to work out the new sample basis timestamp + * after seeking and resynchronization. + */ +/* package */ interface Seeker extends SeekMap { + + /** + * Maps a position (byte offset) to a corresponding sample timestamp. + * + * @param position A seek position (byte offset) relative to the start of the stream. + * @return The corresponding timestamp of the next sample to be read, in microseconds. + */ + long getTimeUs(long position); + + /** + * Returns the position (byte offset) in the stream that is immediately after audio data, or + * {@link C#POSITION_UNSET} if not known. + */ + long getDataEndPosition(); + + /** A {@link Seeker} that does not support seeking through audio data. */ + /* package */ class UnseekableSeeker extends SeekMap.Unseekable implements Seeker { + + public UnseekableSeeker() { + super(/* durationUs= */ C.TIME_UNSET); + } + + @Override + public long getTimeUs(long position) { + return 0; + } + + @Override + public long getDataEndPosition() { + // Position unset as we do not know the data end position. Note that returning 0 doesn't work. + return C.POSITION_UNSET; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java new file mode 100644 index 0000000000..8bb142f496 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** MP3 seeker that uses metadata from a VBRI header. */ +/* package */ final class VbriSeeker implements Seeker { + + private static final String TAG = "VbriSeeker"; + + /** + * Returns a {@link VbriSeeker} for seeking in the stream, if required information is present. + * Returns {@code null} if not. On returning, {@code frame}'s position is not specified so the + * caller should reset it. + * + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param position The position of the start of this frame in the stream. + * @param mpegAudioHeader The MPEG audio header associated with the frame. + * @param frame The data in this audio frame, with its position set to immediately after the + * 'VBRI' tag. + * @return A {@link VbriSeeker} for seeking in the stream, or {@code null} if the required + * information is not present. + */ + public static @Nullable VbriSeeker create( + long inputLength, long position, MpegAudioHeader mpegAudioHeader, ParsableByteArray frame) { + frame.skipBytes(10); + int numFrames = frame.readInt(); + if (numFrames <= 0) { + return null; + } + int sampleRate = mpegAudioHeader.sampleRate; + long durationUs = Util.scaleLargeTimestamp(numFrames, + C.MICROS_PER_SECOND * (sampleRate >= 32000 ? 1152 : 576), sampleRate); + int entryCount = frame.readUnsignedShort(); + int scale = frame.readUnsignedShort(); + int entrySize = frame.readUnsignedShort(); + frame.skipBytes(2); + + long minPosition = position + mpegAudioHeader.frameSize; + // Read table of contents entries. + long[] timesUs = new long[entryCount]; + long[] positions = new long[entryCount]; + for (int index = 0; index < entryCount; index++) { + timesUs[index] = (index * durationUs) / entryCount; + // Ensure positions do not fall within the frame containing the VBRI header. This constraint + // will normally only apply to the first entry in the table. + positions[index] = Math.max(position, minPosition); + int segmentSize; + switch (entrySize) { + case 1: + segmentSize = frame.readUnsignedByte(); + break; + case 2: + segmentSize = frame.readUnsignedShort(); + break; + case 3: + segmentSize = frame.readUnsignedInt24(); + break; + case 4: + segmentSize = frame.readUnsignedIntToInt(); + break; + default: + return null; + } + position += segmentSize * scale; + } + if (inputLength != C.LENGTH_UNSET && inputLength != position) { + Log.w(TAG, "VBRI data size mismatch: " + inputLength + ", " + position); + } + return new VbriSeeker(timesUs, positions, durationUs, /* dataEndPosition= */ position); + } + + private final long[] timesUs; + private final long[] positions; + private final long durationUs; + private final long dataEndPosition; + + private VbriSeeker(long[] timesUs, long[] positions, long durationUs, long dataEndPosition) { + this.timesUs = timesUs; + this.positions = positions; + this.durationUs = durationUs; + this.dataEndPosition = dataEndPosition; + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + int tableIndex = Util.binarySearchFloor(timesUs, timeUs, true, true); + SeekPoint seekPoint = new SeekPoint(timesUs[tableIndex], positions[tableIndex]); + if (seekPoint.timeUs >= timeUs || tableIndex == timesUs.length - 1) { + return new SeekPoints(seekPoint); + } else { + SeekPoint nextSeekPoint = new SeekPoint(timesUs[tableIndex + 1], positions[tableIndex + 1]); + return new SeekPoints(seekPoint, nextSeekPoint); + } + } + + @Override + public long getTimeUs(long position) { + return timesUs[Util.binarySearchFloor(positions, position, true, true)]; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public long getDataEndPosition() { + return dataEndPosition; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java new file mode 100644 index 0000000000..61568aac93 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** MP3 seeker that uses metadata from a Xing header. */ +/* package */ final class XingSeeker implements Seeker { + + private static final String TAG = "XingSeeker"; + + /** + * Returns a {@link XingSeeker} for seeking in the stream, if required information is present. + * Returns {@code null} if not. On returning, {@code frame}'s position is not specified so the + * caller should reset it. + * + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param position The position of the start of this frame in the stream. + * @param mpegAudioHeader The MPEG audio header associated with the frame. + * @param frame The data in this audio frame, with its position set to immediately after the + * 'Xing' or 'Info' tag. + * @return A {@link XingSeeker} for seeking in the stream, or {@code null} if the required + * information is not present. + */ + public static @Nullable XingSeeker create( + long inputLength, long position, MpegAudioHeader mpegAudioHeader, ParsableByteArray frame) { + int samplesPerFrame = mpegAudioHeader.samplesPerFrame; + int sampleRate = mpegAudioHeader.sampleRate; + + int flags = frame.readInt(); + int frameCount; + if ((flags & 0x01) != 0x01 || (frameCount = frame.readUnsignedIntToInt()) == 0) { + // If the frame count is missing/invalid, the header can't be used to determine the duration. + return null; + } + long durationUs = Util.scaleLargeTimestamp(frameCount, samplesPerFrame * C.MICROS_PER_SECOND, + sampleRate); + if ((flags & 0x06) != 0x06) { + // If the size in bytes or table of contents is missing, the stream is not seekable. + return new XingSeeker(position, mpegAudioHeader.frameSize, durationUs); + } + + long dataSize = frame.readUnsignedIntToInt(); + long[] tableOfContents = new long[100]; + for (int i = 0; i < 100; i++) { + tableOfContents[i] = frame.readUnsignedByte(); + } + + // TODO: Handle encoder delay and padding in 3 bytes offset by xingBase + 213 bytes: + // delay = (frame.readUnsignedByte() << 4) + (frame.readUnsignedByte() >> 4); + // padding = ((frame.readUnsignedByte() & 0x0F) << 8) + frame.readUnsignedByte(); + + if (inputLength != C.LENGTH_UNSET && inputLength != position + dataSize) { + Log.w(TAG, "XING data size mismatch: " + inputLength + ", " + (position + dataSize)); + } + return new XingSeeker( + position, mpegAudioHeader.frameSize, durationUs, dataSize, tableOfContents); + } + + private final long dataStartPosition; + private final int xingFrameSize; + private final long durationUs; + /** Data size, including the XING frame. */ + private final long dataSize; + + private final long dataEndPosition; + /** + * Entries are in the range [0, 255], but are stored as long integers for convenience. Null if the + * table of contents was missing from the header, in which case seeking is not be supported. + */ + @Nullable private final long[] tableOfContents; + + private XingSeeker(long dataStartPosition, int xingFrameSize, long durationUs) { + this( + dataStartPosition, + xingFrameSize, + durationUs, + /* dataSize= */ C.LENGTH_UNSET, + /* tableOfContents= */ null); + } + + private XingSeeker( + long dataStartPosition, + int xingFrameSize, + long durationUs, + long dataSize, + @Nullable long[] tableOfContents) { + this.dataStartPosition = dataStartPosition; + this.xingFrameSize = xingFrameSize; + this.durationUs = durationUs; + this.tableOfContents = tableOfContents; + this.dataSize = dataSize; + dataEndPosition = dataSize == C.LENGTH_UNSET ? C.POSITION_UNSET : dataStartPosition + dataSize; + } + + @Override + public boolean isSeekable() { + return tableOfContents != null; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + if (!isSeekable()) { + return new SeekPoints(new SeekPoint(0, dataStartPosition + xingFrameSize)); + } + timeUs = Util.constrainValue(timeUs, 0, durationUs); + double percent = (timeUs * 100d) / durationUs; + double scaledPosition; + if (percent <= 0) { + scaledPosition = 0; + } else if (percent >= 100) { + scaledPosition = 256; + } else { + int prevTableIndex = (int) percent; + long[] tableOfContents = Assertions.checkNotNull(this.tableOfContents); + double prevScaledPosition = tableOfContents[prevTableIndex]; + double nextScaledPosition = prevTableIndex == 99 ? 256 : tableOfContents[prevTableIndex + 1]; + // Linearly interpolate between the two scaled positions. + double interpolateFraction = percent - prevTableIndex; + scaledPosition = prevScaledPosition + + (interpolateFraction * (nextScaledPosition - prevScaledPosition)); + } + long positionOffset = Math.round((scaledPosition / 256) * dataSize); + // Ensure returned positions skip the frame containing the XING header. + positionOffset = Util.constrainValue(positionOffset, xingFrameSize, dataSize - 1); + return new SeekPoints(new SeekPoint(timeUs, dataStartPosition + positionOffset)); + } + + @Override + public long getTimeUs(long position) { + long positionOffset = position - dataStartPosition; + if (!isSeekable() || positionOffset <= xingFrameSize) { + return 0L; + } + long[] tableOfContents = Assertions.checkNotNull(this.tableOfContents); + double scaledPosition = (positionOffset * 256d) / dataSize; + int prevTableIndex = Util.binarySearchFloor(tableOfContents, (long) scaledPosition, true, true); + long prevTimeUs = getTimeUsForTableIndex(prevTableIndex); + long prevScaledPosition = tableOfContents[prevTableIndex]; + long nextTimeUs = getTimeUsForTableIndex(prevTableIndex + 1); + long nextScaledPosition = prevTableIndex == 99 ? 256 : tableOfContents[prevTableIndex + 1]; + // Linearly interpolate between the two table entries. + double interpolateFraction = prevScaledPosition == nextScaledPosition ? 0 + : ((scaledPosition - prevScaledPosition) / (nextScaledPosition - prevScaledPosition)); + return prevTimeUs + Math.round(interpolateFraction * (nextTimeUs - prevTimeUs)); + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public long getDataEndPosition() { + return dataEndPosition; + } + + /** + * Returns the time in microseconds for a given table index. + * + * @param tableIndex A table index in the range [0, 100]. + * @return The corresponding time in microseconds. + */ + private long getTimeUsForTableIndex(int tableIndex) { + return (durationUs * tableIndex) / 100; + } + +} |