summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3')
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java46
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java125
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java482
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java60
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java136
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java188
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;
+ }
+
+}