public final class

Mp3Extractor

extends java.lang.Object

implements Extractor

 java.lang.Object

↳androidx.media3.extractor.mp3.Mp3Extractor

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-extractor', version: '1.0.0-alpha03'

  • groupId: androidx.media3
  • artifactId: media3-extractor
  • version: 1.0.0-alpha03

Artifact androidx.media3:media3-extractor:1.0.0-alpha03 it located at Google repository (https://maven.google.com/)

Overview

Extracts data from the MP3 container format.

Summary

Fields
public static final ExtractorsFactoryFACTORY

Factory for Mp3Extractor instances.

public static final intFLAG_DISABLE_ID3_METADATA

Flag to disable parsing of ID3 metadata.

public static final intFLAG_ENABLE_CONSTANT_BITRATE_SEEKING

Flag to force enable seeking using a constant bitrate assumption in cases where seeking would otherwise not be possible.

public static final intFLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS

Like Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING, except that seeking is also enabled in cases where the content length (and hence the duration of the media) is unknown.

public static final intFLAG_ENABLE_INDEX_SEEKING

Flag to force index seeking, in which a time-to-byte mapping is built as the file is read.

Constructors
publicMp3Extractor()

publicMp3Extractor(int flags)

publicMp3Extractor(int flags, long forcedFirstSampleTimestampUs)

Methods
public voiddisableSeeking()

Disables the extractor from being able to seek through the media.

public voidinit(ExtractorOutput output)

public intread(ExtractorInput input, PositionHolder seekPosition)

public voidrelease()

public voidseek(long position, long timeUs)

public booleansniff(ExtractorInput input)

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Fields

public static final ExtractorsFactory FACTORY

Factory for Mp3Extractor instances.

public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING

Flag to force enable seeking using a constant bitrate assumption in cases where seeking would otherwise not be possible.

This flag is ignored if Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING is set.

public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS

Like Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING, except that seeking is also enabled in cases where the content length (and hence the duration of the media) is unknown. Application code should ensure that requested seek positions are valid when using this flag, or be ready to handle playback failures reported through with PlaybackException.errorCode set to PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE.

If this flag is set, then the behavior enabled by Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING is implicitly enabled.

This flag is ignored if Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING is set.

public static final int FLAG_ENABLE_INDEX_SEEKING

Flag to force index seeking, in which a time-to-byte mapping is built as the file is read.

This seeker may require to scan a significant portion of the file to compute a seek point. Therefore, it should only be used if one of the following is true:

  • The file is small.
  • The bitrate is variable (or it's unknown whether it's variable) and the file does not provide precise enough seeking metadata.

public static final int FLAG_DISABLE_ID3_METADATA

Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not required.

Constructors

public Mp3Extractor()

public Mp3Extractor(int flags)

Parameters:

flags: Flags that control the extractor's behavior.

public Mp3Extractor(int flags, long forcedFirstSampleTimestampUs)

Parameters:

flags: Flags that control the extractor's behavior.
forcedFirstSampleTimestampUs: A timestamp to force for the first sample, or C.TIME_UNSET if forcing is not required.

Methods

public boolean sniff(ExtractorInput input)

public void init(ExtractorOutput output)

public void seek(long position, long timeUs)

public void release()

public int read(ExtractorInput input, PositionHolder seekPosition)

public void disableSeeking()

Disables the extractor from being able to seek through the media.

Please note that this needs to be called before Mp3Extractor.read(ExtractorInput, PositionHolder).

Source

/*
 * 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 androidx.media3.extractor.mp3;

import static java.lang.annotation.ElementType.TYPE_USE;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.ParserException;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.DummyTrackOutput;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.GaplessInfoHolder;
import androidx.media3.extractor.Id3Peeker;
import androidx.media3.extractor.MpegAudioUtil;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.TrackOutput;
import androidx.media3.extractor.metadata.id3.Id3Decoder;
import androidx.media3.extractor.metadata.id3.Id3Decoder.FramePredicate;
import androidx.media3.extractor.metadata.id3.MlltFrame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.media3.extractor.mp3.Seeker.UnseekableSeeker;
import java.io.EOFException;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/** Extracts data from the MP3 container format. */
@UnstableApi
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}, {@link #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS},
   * {@link #FLAG_ENABLE_INDEX_SEEKING} and {@link #FLAG_DISABLE_ID3_METADATA}.
   */
  @Documented
  @Retention(RetentionPolicy.SOURCE)
  @Target(TYPE_USE)
  @IntDef(
      flag = true,
      value = {
        FLAG_ENABLE_CONSTANT_BITRATE_SEEKING,
        FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS,
        FLAG_ENABLE_INDEX_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.
   *
   * <p>This flag is ignored if {@link #FLAG_ENABLE_INDEX_SEEKING} is set.
   */
  public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING = 1;
  /**
   * Like {@link #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING}, except that seeking is also enabled in
   * cases where the content length (and hence the duration of the media) is unknown. Application
   * code should ensure that requested seek positions are valid when using this flag, or be ready to
   * handle playback failures reported through {@link Player.Listener#onPlayerError} with {@link
   * PlaybackException#errorCode} set to {@link
   * PlaybackException#ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE}.
   *
   * <p>If this flag is set, then the behavior enabled by {@link
   * #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING} is implicitly enabled.
   *
   * <p>This flag is ignored if {@link #FLAG_ENABLE_INDEX_SEEKING} is set.
   */
  public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS = 1 << 1;

  /**
   * Flag to force index seeking, in which a time-to-byte mapping is built as the file is read.
   *
   * <p>This seeker may require to scan a significant portion of the file to compute a seek point.
   * Therefore, it should only be used if one of the following is true:
   *
   * <ul>
   *   <li>The file is small.
   *   <li>The bitrate is variable (or it's unknown whether it's variable) and the file does not
   *       provide precise enough seeking metadata.
   * </ul>
   */
  public static final int FLAG_ENABLE_INDEX_SEEKING = 1 << 2;
  /**
   * 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 << 3;

  /** 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 = 32 * 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;

  private final @Flags int flags;
  private final long forcedFirstSampleTimestampUs;
  private final ParsableByteArray scratch;
  private final MpegAudioUtil.Header synchronizedHeader;
  private final GaplessInfoHolder gaplessInfoHolder;
  private final Id3Peeker id3Peeker;
  private final TrackOutput skippingTrackOutput;

  private @MonotonicNonNull ExtractorOutput extractorOutput;
  private @MonotonicNonNull TrackOutput realTrackOutput;
  private TrackOutput currentTrackOutput; // skippingTrackOutput or realTrackOutput.

  private int synchronizedHeaderData;

  @Nullable private Metadata metadata;
  private long basisTimeUs;
  private long samplesRead;
  private long firstSamplePosition;
  private int sampleBytesRemaining;

  private @MonotonicNonNull Seeker seeker;
  private boolean disableSeeking;
  private boolean isSeekInProgress;
  private long seekTimeUs;

  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) {
    if ((flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS) != 0) {
      flags |= FLAG_ENABLE_CONSTANT_BITRATE_SEEKING;
    }
    this.flags = flags;
    this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs;
    scratch = new ParsableByteArray(SCRATCH_LENGTH);
    synchronizedHeader = new MpegAudioUtil.Header();
    gaplessInfoHolder = new GaplessInfoHolder();
    basisTimeUs = C.TIME_UNSET;
    id3Peeker = new Id3Peeker();
    skippingTrackOutput = new DummyTrackOutput();
    currentTrackOutput = skippingTrackOutput;
  }

  // Extractor implementation.

  @Override
  public boolean sniff(ExtractorInput input) throws IOException {
    return synchronize(input, true);
  }

  @Override
  public void init(ExtractorOutput output) {
    extractorOutput = output;
    realTrackOutput = extractorOutput.track(0, C.TRACK_TYPE_AUDIO);
    currentTrackOutput = realTrackOutput;
    extractorOutput.endTracks();
  }

  @Override
  public void seek(long position, long timeUs) {
    synchronizedHeaderData = 0;
    basisTimeUs = C.TIME_UNSET;
    samplesRead = 0;
    sampleBytesRemaining = 0;
    seekTimeUs = timeUs;
    if (seeker instanceof IndexSeeker && !((IndexSeeker) seeker).isTimeUsInIndex(timeUs)) {
      isSeekInProgress = true;
      currentTrackOutput = skippingTrackOutput;
    }
  }

  @Override
  public void release() {
    // Do nothing
  }

  @Override
  public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException {
    assertInitialized();
    int readResult = readInternal(input);
    if (readResult == RESULT_END_OF_INPUT && seeker instanceof IndexSeeker) {
      // Duration is exact when index seeker is used.
      long durationUs = computeTimeUs(samplesRead);
      if (seeker.getDurationUs() != durationUs) {
        ((IndexSeeker) seeker).setDurationUs(durationUs);
        extractorOutput.seekMap(seeker);
      }
    }
    return readResult;
  }

  /**
   * 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.

  @RequiresNonNull({"extractorOutput", "realTrackOutput"})
  private int readInternal(ExtractorInput input) throws IOException {
    if (synchronizedHeaderData == 0) {
      try {
        synchronize(input, false);
      } catch (EOFException e) {
        return RESULT_END_OF_INPUT;
      }
    }
    if (seeker == null) {
      seeker = computeSeeker(input);
      extractorOutput.seekMap(seeker);
      currentTrackOutput.format(
          new Format.Builder()
              .setSampleMimeType(synchronizedHeader.mimeType)
              .setMaxInputSize(MpegAudioUtil.MAX_FRAME_SIZE_BYTES)
              .setChannelCount(synchronizedHeader.channels)
              .setSampleRate(synchronizedHeader.sampleRate)
              .setEncoderDelay(gaplessInfoHolder.encoderDelay)
              .setEncoderPadding(gaplessInfoHolder.encoderPadding)
              .setMetadata((flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata)
              .build());
      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);
  }

  @RequiresNonNull({"realTrackOutput", "seeker"})
  private int readSample(ExtractorInput extractorInput) throws IOException {
    if (sampleBytesRemaining == 0) {
      extractorInput.resetPeekPosition();
      if (peekEndOfStreamOrHeader(extractorInput)) {
        return RESULT_END_OF_INPUT;
      }
      scratch.setPosition(0);
      int sampleHeaderData = scratch.readInt();
      if (!headersMatch(sampleHeaderData, synchronizedHeaderData)
          || MpegAudioUtil.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;
      }
      synchronizedHeader.setForHeaderData(sampleHeaderData);
      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;
      if (seeker instanceof IndexSeeker) {
        IndexSeeker indexSeeker = (IndexSeeker) seeker;
        // Add seek point corresponding to the next frame instead of the current one to be able to
        // start writing to the realTrackOutput on time when a seek is in progress.
        indexSeeker.maybeAddSeekPoint(
            computeTimeUs(samplesRead + synchronizedHeader.samplesPerFrame),
            extractorInput.getPosition() + synchronizedHeader.frameSize);
        if (isSeekInProgress && indexSeeker.isTimeUsInIndex(seekTimeUs)) {
          isSeekInProgress = false;
          currentTrackOutput = realTrackOutput;
        }
      }
    }
    int bytesAppended = currentTrackOutput.sampleData(extractorInput, sampleBytesRemaining, true);
    if (bytesAppended == C.RESULT_END_OF_INPUT) {
      return RESULT_END_OF_INPUT;
    }
    sampleBytesRemaining -= bytesAppended;
    if (sampleBytesRemaining > 0) {
      return RESULT_CONTINUE;
    }
    currentTrackOutput.sampleMetadata(
        computeTimeUs(samplesRead), C.BUFFER_FLAG_KEY_FRAME, synchronizedHeader.frameSize, 0, null);
    samplesRead += synchronizedHeader.samplesPerFrame;
    sampleBytesRemaining = 0;
    return RESULT_CONTINUE;
  }

  private long computeTimeUs(long samplesRead) {
    return basisTimeUs + samplesRead * C.MICROS_PER_SECOND / synchronizedHeader.sampleRate;
  }

  private boolean synchronize(ExtractorInput input, boolean sniffing) throws IOException {
    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 = MpegAudioUtil.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 ParserException.createForMalformedContainer(
                "Searched too many bytes.", /* cause= */ null);
          }
          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) {
          synchronizedHeader.setForHeaderData(headerData);
          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 {
    if (seeker != null) {
      long dataEndPosition = seeker.getDataEndPosition();
      if (dataEndPosition != C.POSITION_UNSET
          && extractorInput.getPeekPosition() > dataEndPosition - 4) {
        return true;
      }
    }
    try {
      return !extractorInput.peekFully(
          scratch.getData(), /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true);
    } catch (EOFException e) {
      return true;
    }
  }

  private Seeker computeSeeker(ExtractorInput input) throws IOException {
    // 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) {
      return new UnseekableSeeker();
    }

    @Nullable Seeker resultSeeker = null;
    if ((flags & FLAG_ENABLE_INDEX_SEEKING) != 0) {
      long durationUs;
      long dataEndPosition = C.POSITION_UNSET;
      if (metadataSeeker != null) {
        durationUs = metadataSeeker.getDurationUs();
        dataEndPosition = metadataSeeker.getDataEndPosition();
      } else if (seekFrameSeeker != null) {
        durationUs = seekFrameSeeker.getDurationUs();
        dataEndPosition = seekFrameSeeker.getDataEndPosition();
      } else {
        durationUs = getId3TlenUs(metadata);
      }
      resultSeeker =
          new IndexSeeker(
              durationUs, /* dataStartPosition= */ input.getPosition(), dataEndPosition);
    } else if (metadataSeeker != null) {
      resultSeeker = metadataSeeker;
    } else if (seekFrameSeeker != null) {
      resultSeeker = seekFrameSeeker;
    }

    if (resultSeeker == null
        || (!resultSeeker.isSeekable() && (flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) != 0)) {
      resultSeeker =
          getConstantBitrateSeeker(
              input, (flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS) != 0);
    }

    return resultSeeker;
  }

  /**
   * 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.
   */
  @Nullable
  private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException {
    ParsableByteArray frame = new ParsableByteArray(synchronizedHeader.frameSize);
    input.peekFully(frame.getData(), 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);
    @Nullable 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.getData(), 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, /* allowSeeksIfLengthUnknown= */ false);
      }
    } 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, boolean allowSeeksIfLengthUnknown)
      throws IOException {
    input.peekFully(scratch.getData(), 0, 4);
    scratch.setPosition(0);
    synchronizedHeader.setForHeaderData(scratch.readInt());
    return new ConstantBitrateSeeker(
        input.getLength(), input.getPosition(), synchronizedHeader, allowSeeksIfLengthUnknown);
  }

  @EnsuresNonNull({"extractorOutput", "realTrackOutput"})
  private void assertInitialized() {
    Assertions.checkStateNotNull(realTrackOutput);
    Util.castNonNull(extractorOutput);
  }

  /** 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(
      @Nullable 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, getId3TlenUs(metadata));
        }
      }
    }
    return null;
  }

  private static long getId3TlenUs(@Nullable Metadata metadata) {
    if (metadata != null) {
      int length = metadata.length();
      for (int i = 0; i < length; i++) {
        Metadata.Entry entry = metadata.get(i);
        if (entry instanceof TextInformationFrame
            && ((TextInformationFrame) entry).id.equals("TLEN")) {
          return Util.msToUs(Long.parseLong(((TextInformationFrame) entry).value));
        }
      }
    }
    return C.TIME_UNSET;
  }
}