Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@
`FLAG_READ_MFRA_FOR_SEEK_MAP` to the `FragmentedMp4Extractor`, which is
now done by default in `DefaultExtractorsFactory`
([#3088](https://github.com/androidx/media/issues/3088)).
* MP3: Use gapless-aware durations from Xing/Info headers
([#3183](https://github.com/androidx/media/issues/3183)).
* Ignore `av1C` data with unsupported version.
* MP4: Add support for big-endian floating point PCM in `fpcm` boxes.
* Matroska: Parse chapter info to `Chapter` entries in a track's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2233,7 +2233,21 @@ public static byte[] getBytesFromHexString(String hexString) {
*/
@UnstableApi
public static String toHexString(byte[] bytes) {
return BaseEncoding.base16().lowerCase().encode(bytes);
return toHexString(bytes, 0, bytes.length);
}

/**
* Returns a string containing a lower-case hex representation of the bytes provided.
*
* @param bytes The byte data to convert to hex.
* @param offset The offset into data to read from.
* @param length The number of bytes to read from data.
* @return A String containing the hex representation of {@code bytes} (considering {@code offset}
* and {@code length}).
*/
@UnstableApi
public static String toHexString(byte[] bytes, int offset, int length) {
return BaseEncoding.base16().lowerCase().encode(bytes, offset, length);
}

@UnstableApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,13 @@ public void toHexString_returnsHexString() {
assertThat(Util.toHexString(bytes)).isEqualTo("12fc06");
}

@Test
public void toHexString_withOffsetAndLength_returnsHexString() {
byte[] bytes = createByteArray(0x12, 0xFC, 0x06, 0x2B);

assertThat(Util.toHexString(bytes, /* offset= */ 1, /* length= */ 2)).isEqualTo("fc06");
}

@Test
public void getCodecsOfType_withNull_returnsNull() {
assertThat(getCodecsOfType(null, C.TRACK_TYPE_VIDEO)).isNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,32 @@ public long getTimeUsAtPosition(long position) {
return getTimeUsAtPosition(position, firstFrameBytePosition, bitrate);
}

/** Returns the byte position of the first frame in the stream (as passed to the constructor). */
protected final long getFirstFramePosition() {
return firstFrameBytePosition;
}

/**
* Returns the size of each frame in the stream in bytes, or {@code 1} if {@link C#LENGTH_UNSET}
* was passed to the constructor.
*/
protected final int getFrameSize() {
return frameSize;
}

/** Returns the bitrate of the stream (as passed to the constructor). */
protected final int getBitrate() {
return bitrate;
}

/**
* Returns whether seeking is permitted if the stream length is unknown (as passed to the
* constructor).
*/
protected final boolean shouldAllowSeeksIfLengthUnknown() {
return allowSeeksIfLengthUnknown;
}

// Internal methods

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,24 @@
import androidx.media3.common.C;
import androidx.media3.extractor.ConstantBitrateSeekMap;
import androidx.media3.extractor.MpegAudioUtil;
import androidx.media3.extractor.SeekMap.SeekPoints;
import androidx.media3.extractor.SeekPoint;

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

private final long firstFramePosition;
private final int bitrate;
private final int frameSize;
private final boolean allowSeeksIfLengthUnknown;
private final long durationUs;
private final long dataEndPosition;

/**
* Constructs an instance.
*
* <p>The duration exposed from {@link #getDurationUs()} is computed from {@code inputLength} and
* the bitrate of {@code mpegAudioHeader}, or is {@link C#TIME_UNSET} if {@code inputLength} is
* unknown.
*
* @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.
Expand All @@ -53,23 +56,30 @@ public ConstantBitrateSeeker(
mpegAudioHeader.bitrate,
mpegAudioHeader.frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ true);
/* durationUs= */ C.TIME_UNSET);
}

/** See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. */
/**
* See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. Uses
* {@code durationUs} as the duration exposed from {@link #getDurationUs()}, or computes the
* duration from {@code inputLength} and {@code bitrate} if {@code durationUs} is {@link
* C#TIME_UNSET}.
*/
public ConstantBitrateSeeker(
long inputLength,
long firstFramePosition,
int bitrate,
int frameSize,
boolean allowSeeksIfLengthUnknown) {
boolean allowSeeksIfLengthUnknown,
long durationUs) {
this(
inputLength,
firstFramePosition,
bitrate,
frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ true);
/* isEstimated= */ true,
durationUs);
}

private ConstantBitrateSeeker(
Expand All @@ -78,18 +88,16 @@ private ConstantBitrateSeeker(
int bitrate,
int frameSize,
boolean allowSeeksIfLengthUnknown,
boolean isEstimated) {
boolean isEstimated,
long durationUs) {
super(
inputLength,
firstFramePosition,
bitrate,
frameSize,
allowSeeksIfLengthUnknown,
isEstimated);
this.firstFramePosition = firstFramePosition;
this.bitrate = bitrate;
this.frameSize = frameSize;
this.allowSeeksIfLengthUnknown = allowSeeksIfLengthUnknown;
this.durationUs = durationUs;
dataEndPosition = inputLength != C.LENGTH_UNSET ? inputLength : C.INDEX_UNSET;
}

Expand All @@ -98,28 +106,45 @@ public long getTimeUs(long position) {
return getTimeUsAtPosition(position);
}

@Override
public SeekPoints getSeekPoints(long timeUs) {
if (durationUs != C.TIME_UNSET && timeUs >= durationUs && dataEndPosition != C.INDEX_UNSET) {
long finalFramePosition = Math.max(getFirstFramePosition(), dataEndPosition - getFrameSize());
long frameDurationUs = getTimeUsAtPosition(getFirstFramePosition() + getFrameSize());
return new SeekPoints(
new SeekPoint(Math.max(0, durationUs - frameDurationUs), finalFramePosition));
}
return super.getSeekPoints(timeUs);
}

@Override
public long getDataStartPosition() {
return firstFramePosition;
return getFirstFramePosition();
}

@Override
public long getDataEndPosition() {
return dataEndPosition;
}

@Override
public long getDurationUs() {
return durationUs != C.TIME_UNSET ? durationUs : super.getDurationUs();
}

@Override
public int getAverageBitrate() {
return bitrate;
return getBitrate();
}

public ConstantBitrateSeeker copyWithNewDataEndPosition(long dataEndPosition) {
return new ConstantBitrateSeeker(
/* inputLength= */ dataEndPosition,
firstFramePosition,
bitrate,
frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ false);
getFirstFramePosition(),
getAverageBitrate(),
getFrameSize(),
shouldAllowSeeksIfLengthUnknown(),
/* isEstimated= */ false,
durationUs);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package androidx.media3.extractor.mp3;

import static androidx.media3.extractor.mp3.Mp3Util.computeAverageBitrate;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.SOURCE;
Expand Down Expand Up @@ -265,7 +266,7 @@ public int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce
if (readResult == RESULT_END_OF_INPUT && seeker instanceof IndexSeeker) {
// Duration is exact when index seeker is used.
long finalSampleIndex = samplesRead - 1;
long durationUs = finalSampleIndex >= 0 ? computeTimeUs(finalSampleIndex) : C.TIME_UNSET;
long durationUs = finalSampleIndex >= 0 ? computeFinalIndexSeekerDurationUs(finalSampleIndex) : C.TIME_UNSET;
if (seeker.getDurationUs() != durationUs) {
((IndexSeeker) seeker).setDurationUs(durationUs);
extractorOutput.seekMap(seeker);
Expand Down Expand Up @@ -391,6 +392,21 @@ private long computeTimeUs(long samplesRead) {
return basisTimeUs + samplesRead * C.MICROS_PER_SECOND / synchronizedHeader.sampleRate;
}

/**
* Returns the final duration to expose for an {@link IndexSeeker}.
*
* <p>Index seeking finalizes duration from the encoded samples read at EOF. When gapless metadata
* is present, this trims the encoder delay and padding so EOF finalization does not replace an
* initially gapless Xing/Info duration with the longer encoded duration.
*/
private long computeFinalIndexSeekerDurationUs(long finalSampleIndex) {
long finalGaplessSampleIndex =
gaplessInfoHolder.hasGaplessInfo()
? finalSampleIndex - gaplessInfoHolder.encoderDelay - gaplessInfoHolder.encoderPadding
: finalSampleIndex;
return finalGaplessSampleIndex >= 0 ? computeTimeUs(finalGaplessSampleIndex) : C.TIME_UNSET;
}

private boolean synchronize(ExtractorInput input, boolean sniffing) throws IOException {
int validFrameCount = 0;
int candidateSynchronizedHeaderData = 0;
Expand Down Expand Up @@ -522,37 +538,37 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
return resultSeeker;
}

long durationUs = resultSeeker.getDurationUs();
long inputLength =
resultSeeker.getDataEndPosition() != C.INDEX_UNSET
? resultSeeker.getDataEndPosition()
: input.getLength();
if (resultSeeker.getDurationUs() == C.TIME_UNSET || inputLength == C.LENGTH_UNSET) {
if (durationUs == C.TIME_UNSET || inputLength == C.LENGTH_UNSET) {
// resultSeeker doesn't provide enough info to do 'enhanced' CBR seeking, so we just do
// normal CBR seeking without any additional info from the file.
return getConstantBitrateSeeker(input);
}
// resultSeeker provides a duration and we know the input length, and CBR seeking has been
// requested, so we can do 'enhanced' CBR seeking using this info.
long dataStart =
resultSeeker.getDataStartPosition() != C.INDEX_UNSET
? resultSeeker.getDataStartPosition()
: 0;
long audioLength = inputLength - dataStart;
int bitrate =
Ints.saturatedCast(
Util.scaleLargeValue(
audioLength,
Byte.SIZE * C.MICROS_PER_SECOND,
resultSeeker.getDurationUs(),
RoundingMode.HALF_UP));
// inputLength will never be LENGTH_UNSET because of the if-condition above, so we can
int averageBitrate = computeAverageBitrate(inputLength - dataStart, durationUs);
if (averageBitrate == C.RATE_UNSET_INT) {
// Bitrate couldn't be determined (dataStart >= inputLength?) so we just do normal CBR seeking
return getConstantBitrateSeeker(input);
}

// resultSeeker provides a duration and we know the input length, and CBR seeking has been
// requested, so we can do 'enhanced' CBR seeking using this info. inputLength will never be
// LENGTH_UNSET because of the if-condition above, so we can
// pass (vacuously) false here for allowSeeksIfLengthUnknown.
return new ConstantBitrateSeeker(
inputLength,
dataStart,
bitrate,
averageBitrate,
/* frameSize= */ C.LENGTH_UNSET,
/* allowSeeksIfLengthUnknown= */ false);
/* allowSeeksIfLengthUnknown= */ false,
durationUs);
}

private boolean shouldFallbackToConstantBitrateSeeking(Seeker seeker) {
Expand Down Expand Up @@ -663,15 +679,13 @@ private Seeker getConstantBitrateSeeker(

// Derive the bitrate and frame size by averaging over the length of playable audio, to allow
// for 'mostly' CBR streams that might have a small number of frames with a different bitrate.
// We can assume infoFrame.frameCount is set, because otherwise computeDurationUs() would
// have returned C.TIME_UNSET above. See also https://github.com/androidx/media/issues/1376.
int averageBitrate =
Ints.checkedCast(
Util.scaleLargeValue(
audioLength,
C.BITS_PER_BYTE * C.MICROS_PER_SECOND,
durationUs,
RoundingMode.HALF_UP));
// See also https://github.com/androidx/media/issues/1376.
int averageBitrate = computeAverageBitrate(audioLength, durationUs);
if (averageBitrate == C.RATE_UNSET_INT) {
Comment thread
icbaker marked this conversation as resolved.
// Invalid Info sizes or durations should fall back to the next frame header bitrate rather
// than constructing a ConstantBitrateSeeker with an unset bitrate.
return null;
}
int frameSize =
Ints.checkedCast(LongMath.divide(audioLength, infoFrame.frameCount, RoundingMode.HALF_UP));
// Set the seeker frame size to the average frame size (even though some constant bitrate
Expand All @@ -682,7 +696,8 @@ private Seeker getConstantBitrateSeeker(
/* firstFramePosition= */ infoFramePosition + infoFrame.header.frameSize,
averageBitrate,
frameSize,
/* allowSeeksIfLengthUnknown= */ false);
/* allowSeeksIfLengthUnknown= */ false,
durationUs);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,24 @@ public static XingFrame parse(MpegAudioUtil.Header mpegAudioHeader, ParsableByte

/**
* Compute the stream duration, in microseconds, represented by this frame. Returns {@link
* C#LENGTH_UNSET} if the frame doesn't contain enough information to compute a duration.
* C#TIME_UNSET} if the frame doesn't contain enough information to compute a duration. Encoder
* delay and padding are subtracted if present.
*/
// TODO: b/319235116 - Handle encoder delay and padding when calculating duration.
public long computeDurationUs() {
if (frameCount == C.LENGTH_UNSET || frameCount == 0) {
// If the frame count is missing/invalid, the header can't be used to determine the duration.
return C.TIME_UNSET;
}
long sampleCount = frameCount * header.samplesPerFrame;
if (encoderDelay != C.LENGTH_UNSET && encoderPadding != C.LENGTH_UNSET) {
sampleCount -= encoderDelay + encoderPadding;
}
if (sampleCount <= 0) {
return C.TIME_UNSET;
}
// Audio requires both a start and end PCM sample, so subtract one from the sample count before
// calculating the duration.
return Util.sampleCountToDurationUs(
(frameCount * header.samplesPerFrame) - 1, header.sampleRate);
return Util.sampleCountToDurationUs(sampleCount - 1, header.sampleRate);
}

/** Provide the metadata derived from this Xing frame, such as ReplayGain data. */
Expand Down
Loading