Skip to content
Open
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
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
([#3088](https://github.com/androidx/media/issues/3088)).
* MP3: Ignore Xing data length if it's longer than the known stream length
([#3117](https://github.com/androidx/media/issues/3117)).
* MP3: Use gapless-aware durations from Xing/Info headers while keeping
raw durations for bitrate calculations
([#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 @@ -18,6 +18,8 @@
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.
Expand All @@ -28,6 +30,7 @@
private final int bitrate;
private final int frameSize;
private final boolean allowSeeksIfLengthUnknown;
private final long durationUs;
private final long dataEndPosition;

/**
Expand All @@ -53,7 +56,7 @@ public ConstantBitrateSeeker(
mpegAudioHeader.bitrate,
mpegAudioHeader.frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ true);
/* durationUs= */ C.TIME_UNSET);
}

/** See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. */
Expand All @@ -69,7 +72,29 @@ public ConstantBitrateSeeker(
bitrate,
frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ true);
/* durationUs= */ C.TIME_UNSET);
}

/**
* See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. Uses
* {@code durationUs} as the duration exposed from {@link #getDurationUs()}, while keeping the
* duration derived from {@code inputLength} and {@code bitrate} for raw seek calculations.
*/
public ConstantBitrateSeeker(
long inputLength,
long firstFramePosition,
int bitrate,
int frameSize,
boolean allowSeeksIfLengthUnknown,
long durationUs) {
this(
inputLength,
firstFramePosition,
bitrate,
frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ true,
durationUs);
}

private ConstantBitrateSeeker(
Expand All @@ -78,7 +103,8 @@ private ConstantBitrateSeeker(
int bitrate,
int frameSize,
boolean allowSeeksIfLengthUnknown,
boolean isEstimated) {
boolean isEstimated,
long durationUs) {
super(
inputLength,
firstFramePosition,
Expand All @@ -90,6 +116,7 @@ private ConstantBitrateSeeker(
this.bitrate = bitrate;
this.frameSize = frameSize;
this.allowSeeksIfLengthUnknown = allowSeeksIfLengthUnknown;
this.durationUs = durationUs;
dataEndPosition = inputLength != C.LENGTH_UNSET ? inputLength : C.INDEX_UNSET;
}

Expand All @@ -98,6 +125,18 @@ public long getTimeUs(long position) {
return getTimeUsAtPosition(position);
}

@Override
public SeekPoints getSeekPoints(long timeUs) {
long rawDurationUs = getRawDurationUs();
if (durationUs != C.TIME_UNSET && rawDurationUs != C.TIME_UNSET && timeUs >= durationUs) {
// Use the raw duration only to find the final byte position. Keep the returned seek point on
// the exposed gapless timeline.
SeekPoints rawDurationSeekPoints = super.getSeekPoints(rawDurationUs);
return new SeekPoints(new SeekPoint(durationUs, rawDurationSeekPoints.first.position));
}
return super.getSeekPoints(timeUs);
}

@Override
public long getDataStartPosition() {
return firstFramePosition;
Expand All @@ -108,6 +147,16 @@ public long getDataEndPosition() {
return dataEndPosition;
}

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

@Override
public long getRawDurationUs() {
return super.getDurationUs();
}

@Override
public int getAverageBitrate() {
return bitrate;
Expand All @@ -120,6 +169,7 @@ public ConstantBitrateSeeker copyWithNewDataEndPosition(long dataEndPosition) {
bitrate,
frameSize,
allowSeeksIfLengthUnknown,
/* isEstimated= */ false);
/* isEstimated= */ false,
durationUs);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
}

if (shouldFallbackToConstantBitrateSeeking(resultSeeker)
&& resultSeeker.getDurationUs() != C.TIME_UNSET
&& resultSeeker.getRawDurationUs() != C.TIME_UNSET
&& (resultSeeker.getDataEndPosition() != C.INDEX_UNSET
|| input.getLength() != C.LENGTH_UNSET)) {
// resultSeeker does not allow seeking, but does provide a duration and constant bitrate
Expand All @@ -532,7 +532,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
Util.scaleLargeValue(
audioLength,
Byte.SIZE * C.MICROS_PER_SECOND,
resultSeeker.getDurationUs(),
resultSeeker.getRawDurationUs(),
RoundingMode.HALF_UP));
// inputLength will never be LENGTH_UNSET because of the outer if-condition, so we can pass
// (vacuously) false here for allowSeeksIfLengthUnknown.
Expand All @@ -542,7 +542,8 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
dataStart,
bitrate,
C.LENGTH_UNSET,
/* allowSeeksIfLengthUnknown= */ false);
/* allowSeeksIfLengthUnknown= */ false,
resultSeeker.getDurationUs());
} else if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
// Either we found no seek or VBR info, so we must assume the file is CBR (even without the
// flag(s) being set), or an 'enable CBR seeking flag' is set and we found some seek info, but
Expand Down Expand Up @@ -642,8 +643,9 @@ private Seeker getConstantBitrateSeeker(ExtractorInput input, boolean allowSeeks
@Nullable
private Seeker getConstantBitrateSeeker(
long infoFramePosition, XingFrame infoFrame, long fallbackStreamLength) {
long durationUs = infoFrame.computeDurationUs();
if (durationUs == C.TIME_UNSET) {
long rawDurationUs = infoFrame.computeRawDurationUs();
long durationUs = infoFrame.computeGaplessDurationUs();
if (rawDurationUs == C.TIME_UNSET || durationUs == C.TIME_UNSET) {
return null;
}
long streamLength;
Expand All @@ -663,14 +665,15 @@ 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.
// We can assume infoFrame.frameCount is set, because otherwise computeRawDurationUs() would
// have returned C.TIME_UNSET above. Use the raw duration so encoder delay/padding does not
// inflate the derived bitrate. 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,
rawDurationUs,
RoundingMode.HALF_UP));
int frameSize =
Ints.checkedCast(LongMath.divide(audioLength, infoFrame.frameCount, RoundingMode.HALF_UP));
Expand All @@ -682,7 +685,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 @@ -51,6 +51,14 @@
*/
int getAverageBitrate();

/**
* Returns the raw duration before subtracting encoder delay/padding, or {@link C#TIME_UNSET} if
* not known.
*/
default long getRawDurationUs() {
return getDurationUs();
}

/** A {@link Seeker} that does not support seeking through audio data. */
/* package */ class UnseekableSeeker extends SeekMap.Unseekable implements Seeker {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,19 +140,40 @@ 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.
* Compute the raw stream duration, in microseconds, represented by this frame. Returns {@link
* C#TIME_UNSET} if the frame doesn't contain enough information to compute a duration.
*/
// TODO: b/319235116 - Handle encoder delay and padding when calculating duration.
public long computeDurationUs() {
public long computeRawDurationUs() {
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;
}
return computeDurationUs(/* sampleCount= */ frameCount * header.samplesPerFrame);
}

/**
* Compute the gapless playback duration, in microseconds, represented by this frame. Returns
* {@link C#TIME_UNSET} if the frame doesn't contain enough information to compute a duration.
*/
public long computeGaplessDurationUs() {
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;
}
return computeDurationUs(sampleCount);
}

private long computeDurationUs(long sampleCount) {
// 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
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@
*/
@Nullable
public static XingSeeker create(XingFrame xingFrame, long position, long streamLength) {
long durationUs = xingFrame.computeDurationUs();
if (durationUs == C.TIME_UNSET) {
long rawDurationUs = xingFrame.computeRawDurationUs();
long durationUs = xingFrame.computeGaplessDurationUs();
if (rawDurationUs == C.TIME_UNSET || durationUs == C.TIME_UNSET) {
return null;
}
long dataSize;
Expand All @@ -65,6 +66,7 @@ public static XingSeeker create(XingFrame xingFrame, long position, long streamL
position,
xingFrame.header.frameSize,
durationUs,
rawDurationUs,
xingFrame.header.bitrate,
dataSize,
xingFrame.tableOfContents);
Expand All @@ -73,6 +75,7 @@ public static XingSeeker create(XingFrame xingFrame, long position, long streamL
private final long dataStartPosition;
private final int xingFrameSize;
private final long durationUs;
private final long rawDurationUs;
private final int bitrate;

/** Data size, including the XING frame. */
Expand All @@ -90,12 +93,14 @@ private XingSeeker(
long dataStartPosition,
int xingFrameSize,
long durationUs,
long rawDurationUs,
int bitrate,
long dataSize,
@Nullable long[] tableOfContents) {
this.dataStartPosition = dataStartPosition;
this.xingFrameSize = xingFrameSize;
this.durationUs = durationUs;
this.rawDurationUs = rawDurationUs;
this.bitrate = bitrate;
this.dataSize = dataSize;
this.tableOfContents = tableOfContents;
Expand Down Expand Up @@ -161,6 +166,11 @@ public long getDurationUs() {
return durationUs;
}

@Override
public long getRawDurationUs() {
return rawDurationUs;
}

@Override
public long getDataStartPosition() {
return dataStartPosition + xingFrameSize;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.SeekPoint;
import androidx.media3.test.utils.FakeExtractorOutput;
import androidx.media3.test.utils.FakeTrackOutput;
import androidx.media3.test.utils.TestUtil;
Expand Down Expand Up @@ -66,6 +67,24 @@ public void mp3ExtractorReads_returnSeekableCbrSeeker() throws IOException {
assertThat(seekMap.isSeekable()).isTrue();
}

@Test
public void getDurationUs_withExplicitDuration_keepsRawDurationForSeekCalculations() {
ConstantBitrateSeeker seeker =
new ConstantBitrateSeeker(
/* inputLength= */ 1_125,
/* firstFramePosition= */ 125,
/* bitrate= */ 8_000,
/* frameSize= */ 1,
/* allowSeeksIfLengthUnknown= */ false,
/* durationUs= */ 900_000);

assertThat(seeker.getDurationUs()).isEqualTo(900_000);
assertThat(seeker.getRawDurationUs()).isEqualTo(1_000_000);
assertThat(seeker.getTimeUs(1_025)).isEqualTo(900_000);
assertThat(seeker.getSeekPoints(800_000).first.position).isEqualTo(925);
assertThat(seeker.getSeekPoints(900_000).first).isEqualTo(new SeekPoint(900_000, 1_124));
}

@Test
public void seeking_handlesSeekToZero() throws IOException {
String fileName = CONSTANT_FRAME_SIZE_TEST_FILE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ public void mp3SampleWithInfoHeader(
simulationConfig);
}

@Test
public void mp3SampleWithInfoHeader_usesGaplessDurationAndRawBitrate() throws Exception {
FakeExtractorOutput output =
TestUtil.extractAllSamplesFromFile(
new Mp3Extractor(),
ApplicationProvider.getApplicationContext(),
"media/mp3/test-cbr-info-header.mp3");

assertThat(output.seekMap.getDurationUs()).isEqualTo(999_977);
assertThat(output.trackOutputs.get(0).getDurationUs()).isEqualTo(999_977);
assertThat(output.trackOutputs.get(0).lastFormat.averageBitrate).isEqualTo(64_000);
}

// https://github.com/androidx/media/issues/1376#issuecomment-2117393653
@Test
public void mp3SampleWithInfoHeaderAndPcutFrame(
Expand Down
Loading