Skip to content

Commit 09b7ad2

Browse files
committed
Use gapless MP3 Xing/Info durations in seek maps
Xing/Info frame counts describe the raw MP3 frame timeline, but SeekMap#getDurationUs should expose the gapless playback duration when encoder delay and padding are present. Keep both values in MP3 seekers: use the raw duration for bitrate and byte-position calculations, and use the gapless duration for the public duration and end-of-stream seek points. This makes getPosition(durationUs) return a seek point whose timeUs is the requested public duration. Issue: #3183 Test: ./gradlew :lib-extractor:testDebugUnitTest --tests androidx.media3.extractor.mp3.ConstantBitrateSeekerTest --tests androidx.media3.extractor.mp3.Mp3ExtractorTest --tests androidx.media3.extractor.mp3.XingFrameTest
1 parent 892af5f commit 09b7ad2

46 files changed

Lines changed: 639 additions & 387 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RELEASENOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777
([#3088](https://github.com/androidx/media/issues/3088)).
7878
* MP3: Ignore Xing data length if it's longer than the known stream length
7979
([#3117](https://github.com/androidx/media/issues/3117)).
80+
* MP3: Use gapless-aware durations from Xing/Info headers while keeping
81+
raw durations for bitrate calculations
82+
([#3183](https://github.com/androidx/media/issues/3183)).
8083
* Ignore `av1C` data with unsupported version.
8184
* MP4: Add support for big-endian floating point PCM in `fpcm` boxes.
8285
* Matroska: Parse chapter info to `Chapter` entries in a track's

libraries/extractor/src/main/java/androidx/media3/extractor/mp3/ConstantBitrateSeeker.java

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import androidx.media3.common.C;
1919
import androidx.media3.extractor.ConstantBitrateSeekMap;
2020
import androidx.media3.extractor.MpegAudioUtil;
21+
import androidx.media3.extractor.SeekMap.SeekPoints;
22+
import androidx.media3.extractor.SeekPoint;
2123

2224
/**
2325
* MP3 seeker that doesn't rely on metadata and seeks assuming the source has a constant bitrate.
@@ -28,6 +30,7 @@
2830
private final int bitrate;
2931
private final int frameSize;
3032
private final boolean allowSeeksIfLengthUnknown;
33+
private final long durationUs;
3134
private final long dataEndPosition;
3235

3336
/**
@@ -53,7 +56,7 @@ public ConstantBitrateSeeker(
5356
mpegAudioHeader.bitrate,
5457
mpegAudioHeader.frameSize,
5558
allowSeeksIfLengthUnknown,
56-
/* isEstimated= */ true);
59+
/* durationUs= */ C.TIME_UNSET);
5760
}
5861

5962
/** See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. */
@@ -69,7 +72,29 @@ public ConstantBitrateSeeker(
6972
bitrate,
7073
frameSize,
7174
allowSeeksIfLengthUnknown,
72-
/* isEstimated= */ true);
75+
/* durationUs= */ C.TIME_UNSET);
76+
}
77+
78+
/**
79+
* See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. Uses
80+
* {@code durationUs} as the duration exposed from {@link #getDurationUs()}, while keeping the
81+
* duration derived from {@code inputLength} and {@code bitrate} for raw seek calculations.
82+
*/
83+
public ConstantBitrateSeeker(
84+
long inputLength,
85+
long firstFramePosition,
86+
int bitrate,
87+
int frameSize,
88+
boolean allowSeeksIfLengthUnknown,
89+
long durationUs) {
90+
this(
91+
inputLength,
92+
firstFramePosition,
93+
bitrate,
94+
frameSize,
95+
allowSeeksIfLengthUnknown,
96+
/* isEstimated= */ true,
97+
durationUs);
7398
}
7499

75100
private ConstantBitrateSeeker(
@@ -78,7 +103,8 @@ private ConstantBitrateSeeker(
78103
int bitrate,
79104
int frameSize,
80105
boolean allowSeeksIfLengthUnknown,
81-
boolean isEstimated) {
106+
boolean isEstimated,
107+
long durationUs) {
82108
super(
83109
inputLength,
84110
firstFramePosition,
@@ -90,6 +116,7 @@ private ConstantBitrateSeeker(
90116
this.bitrate = bitrate;
91117
this.frameSize = frameSize;
92118
this.allowSeeksIfLengthUnknown = allowSeeksIfLengthUnknown;
119+
this.durationUs = durationUs;
93120
dataEndPosition = inputLength != C.LENGTH_UNSET ? inputLength : C.INDEX_UNSET;
94121
}
95122

@@ -98,6 +125,18 @@ public long getTimeUs(long position) {
98125
return getTimeUsAtPosition(position);
99126
}
100127

128+
@Override
129+
public SeekPoints getSeekPoints(long timeUs) {
130+
long rawDurationUs = getRawDurationUs();
131+
if (durationUs != C.TIME_UNSET && rawDurationUs != C.TIME_UNSET && timeUs >= durationUs) {
132+
// Use the raw duration only to find the final byte position. Keep the returned seek point on
133+
// the exposed gapless timeline.
134+
SeekPoints rawDurationSeekPoints = super.getSeekPoints(rawDurationUs);
135+
return new SeekPoints(new SeekPoint(durationUs, rawDurationSeekPoints.first.position));
136+
}
137+
return super.getSeekPoints(timeUs);
138+
}
139+
101140
@Override
102141
public long getDataStartPosition() {
103142
return firstFramePosition;
@@ -108,6 +147,16 @@ public long getDataEndPosition() {
108147
return dataEndPosition;
109148
}
110149

150+
@Override
151+
public long getDurationUs() {
152+
return durationUs != C.TIME_UNSET ? durationUs : super.getDurationUs();
153+
}
154+
155+
@Override
156+
public long getRawDurationUs() {
157+
return super.getDurationUs();
158+
}
159+
111160
@Override
112161
public int getAverageBitrate() {
113162
return bitrate;
@@ -120,6 +169,7 @@ public ConstantBitrateSeeker copyWithNewDataEndPosition(long dataEndPosition) {
120169
bitrate,
121170
frameSize,
122171
allowSeeksIfLengthUnknown,
123-
/* isEstimated= */ false);
172+
/* isEstimated= */ false,
173+
durationUs);
124174
}
125175
}

libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
513513
}
514514

515515
if (shouldFallbackToConstantBitrateSeeking(resultSeeker)
516-
&& resultSeeker.getDurationUs() != C.TIME_UNSET
516+
&& resultSeeker.getRawDurationUs() != C.TIME_UNSET
517517
&& (resultSeeker.getDataEndPosition() != C.INDEX_UNSET
518518
|| input.getLength() != C.LENGTH_UNSET)) {
519519
// resultSeeker does not allow seeking, but does provide a duration and constant bitrate
@@ -532,7 +532,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
532532
Util.scaleLargeValue(
533533
audioLength,
534534
Byte.SIZE * C.MICROS_PER_SECOND,
535-
resultSeeker.getDurationUs(),
535+
resultSeeker.getRawDurationUs(),
536536
RoundingMode.HALF_UP));
537537
// inputLength will never be LENGTH_UNSET because of the outer if-condition, so we can pass
538538
// (vacuously) false here for allowSeeksIfLengthUnknown.
@@ -542,7 +542,8 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
542542
dataStart,
543543
bitrate,
544544
C.LENGTH_UNSET,
545-
/* allowSeeksIfLengthUnknown= */ false);
545+
/* allowSeeksIfLengthUnknown= */ false,
546+
resultSeeker.getDurationUs());
546547
} else if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
547548
// Either we found no seek or VBR info, so we must assume the file is CBR (even without the
548549
// flag(s) being set), or an 'enable CBR seeking flag' is set and we found some seek info, but
@@ -642,8 +643,9 @@ private Seeker getConstantBitrateSeeker(ExtractorInput input, boolean allowSeeks
642643
@Nullable
643644
private Seeker getConstantBitrateSeeker(
644645
long infoFramePosition, XingFrame infoFrame, long fallbackStreamLength) {
645-
long durationUs = infoFrame.computeDurationUs();
646-
if (durationUs == C.TIME_UNSET) {
646+
long rawDurationUs = infoFrame.computeRawDurationUs();
647+
long durationUs = infoFrame.computeGaplessDurationUs();
648+
if (rawDurationUs == C.TIME_UNSET || durationUs == C.TIME_UNSET) {
647649
return null;
648650
}
649651
long streamLength;
@@ -663,14 +665,15 @@ private Seeker getConstantBitrateSeeker(
663665

664666
// Derive the bitrate and frame size by averaging over the length of playable audio, to allow
665667
// for 'mostly' CBR streams that might have a small number of frames with a different bitrate.
666-
// We can assume infoFrame.frameCount is set, because otherwise computeDurationUs() would
667-
// have returned C.TIME_UNSET above. See also https://github.com/androidx/media/issues/1376.
668+
// We can assume infoFrame.frameCount is set, because otherwise computeRawDurationUs() would
669+
// have returned C.TIME_UNSET above. Use the raw duration so encoder delay/padding does not
670+
// inflate the derived bitrate. See also https://github.com/androidx/media/issues/1376.
668671
int averageBitrate =
669672
Ints.checkedCast(
670673
Util.scaleLargeValue(
671674
audioLength,
672675
C.BITS_PER_BYTE * C.MICROS_PER_SECOND,
673-
durationUs,
676+
rawDurationUs,
674677
RoundingMode.HALF_UP));
675678
int frameSize =
676679
Ints.checkedCast(LongMath.divide(audioLength, infoFrame.frameCount, RoundingMode.HALF_UP));
@@ -682,7 +685,8 @@ private Seeker getConstantBitrateSeeker(
682685
/* firstFramePosition= */ infoFramePosition + infoFrame.header.frameSize,
683686
averageBitrate,
684687
frameSize,
685-
/* allowSeeksIfLengthUnknown= */ false);
688+
/* allowSeeksIfLengthUnknown= */ false,
689+
durationUs);
686690
}
687691

688692
/**

libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Seeker.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@
5151
*/
5252
int getAverageBitrate();
5353

54+
/**
55+
* Returns the raw duration before subtracting encoder delay/padding, or {@link C#TIME_UNSET} if
56+
* not known.
57+
*/
58+
default long getRawDurationUs() {
59+
return getDurationUs();
60+
}
61+
5462
/** A {@link Seeker} that does not support seeking through audio data. */
5563
/* package */ class UnseekableSeeker extends SeekMap.Unseekable implements Seeker {
5664

libraries/extractor/src/main/java/androidx/media3/extractor/mp3/XingFrame.java

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,19 +140,40 @@ public static XingFrame parse(MpegAudioUtil.Header mpegAudioHeader, ParsableByte
140140
}
141141

142142
/**
143-
* Compute the stream duration, in microseconds, represented by this frame. Returns {@link
144-
* C#LENGTH_UNSET} if the frame doesn't contain enough information to compute a duration.
143+
* Compute the raw stream duration, in microseconds, represented by this frame. Returns {@link
144+
* C#TIME_UNSET} if the frame doesn't contain enough information to compute a duration.
145145
*/
146-
// TODO: b/319235116 - Handle encoder delay and padding when calculating duration.
147-
public long computeDurationUs() {
146+
public long computeRawDurationUs() {
148147
if (frameCount == C.LENGTH_UNSET || frameCount == 0) {
149148
// If the frame count is missing/invalid, the header can't be used to determine the duration.
150149
return C.TIME_UNSET;
151150
}
151+
return computeDurationUs(/* sampleCount= */ frameCount * header.samplesPerFrame);
152+
}
153+
154+
/**
155+
* Compute the gapless playback duration, in microseconds, represented by this frame. Returns
156+
* {@link C#TIME_UNSET} if the frame doesn't contain enough information to compute a duration.
157+
*/
158+
public long computeGaplessDurationUs() {
159+
if (frameCount == C.LENGTH_UNSET || frameCount == 0) {
160+
// If the frame count is missing/invalid, the header can't be used to determine the duration.
161+
return C.TIME_UNSET;
162+
}
163+
long sampleCount = frameCount * header.samplesPerFrame;
164+
if (encoderDelay != C.LENGTH_UNSET && encoderPadding != C.LENGTH_UNSET) {
165+
sampleCount -= encoderDelay + encoderPadding;
166+
}
167+
if (sampleCount <= 0) {
168+
return C.TIME_UNSET;
169+
}
170+
return computeDurationUs(sampleCount);
171+
}
172+
173+
private long computeDurationUs(long sampleCount) {
152174
// Audio requires both a start and end PCM sample, so subtract one from the sample count before
153175
// calculating the duration.
154-
return Util.sampleCountToDurationUs(
155-
(frameCount * header.samplesPerFrame) - 1, header.sampleRate);
176+
return Util.sampleCountToDurationUs(sampleCount - 1, header.sampleRate);
156177
}
157178

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

libraries/extractor/src/main/java/androidx/media3/extractor/mp3/XingSeeker.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@
4141
*/
4242
@Nullable
4343
public static XingSeeker create(XingFrame xingFrame, long position, long streamLength) {
44-
long durationUs = xingFrame.computeDurationUs();
45-
if (durationUs == C.TIME_UNSET) {
44+
long rawDurationUs = xingFrame.computeRawDurationUs();
45+
long durationUs = xingFrame.computeGaplessDurationUs();
46+
if (rawDurationUs == C.TIME_UNSET || durationUs == C.TIME_UNSET) {
4647
return null;
4748
}
4849
long dataSize;
@@ -65,6 +66,7 @@ public static XingSeeker create(XingFrame xingFrame, long position, long streamL
6566
position,
6667
xingFrame.header.frameSize,
6768
durationUs,
69+
rawDurationUs,
6870
xingFrame.header.bitrate,
6971
dataSize,
7072
xingFrame.tableOfContents);
@@ -73,6 +75,7 @@ public static XingSeeker create(XingFrame xingFrame, long position, long streamL
7375
private final long dataStartPosition;
7476
private final int xingFrameSize;
7577
private final long durationUs;
78+
private final long rawDurationUs;
7679
private final int bitrate;
7780

7881
/** Data size, including the XING frame. */
@@ -90,12 +93,14 @@ private XingSeeker(
9093
long dataStartPosition,
9194
int xingFrameSize,
9295
long durationUs,
96+
long rawDurationUs,
9397
int bitrate,
9498
long dataSize,
9599
@Nullable long[] tableOfContents) {
96100
this.dataStartPosition = dataStartPosition;
97101
this.xingFrameSize = xingFrameSize;
98102
this.durationUs = durationUs;
103+
this.rawDurationUs = rawDurationUs;
99104
this.bitrate = bitrate;
100105
this.dataSize = dataSize;
101106
this.tableOfContents = tableOfContents;
@@ -161,6 +166,11 @@ public long getDurationUs() {
161166
return durationUs;
162167
}
163168

169+
@Override
170+
public long getRawDurationUs() {
171+
return rawDurationUs;
172+
}
173+
164174
@Override
165175
public long getDataStartPosition() {
166176
return dataStartPosition + xingFrameSize;

libraries/extractor/src/test/java/androidx/media3/extractor/mp3/ConstantBitrateSeekerTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import androidx.media3.common.util.Util;
2323
import androidx.media3.datasource.DefaultDataSource;
2424
import androidx.media3.extractor.SeekMap;
25+
import androidx.media3.extractor.SeekPoint;
2526
import androidx.media3.test.utils.FakeExtractorOutput;
2627
import androidx.media3.test.utils.FakeTrackOutput;
2728
import androidx.media3.test.utils.TestUtil;
@@ -66,6 +67,24 @@ public void mp3ExtractorReads_returnSeekableCbrSeeker() throws IOException {
6667
assertThat(seekMap.isSeekable()).isTrue();
6768
}
6869

70+
@Test
71+
public void getDurationUs_withExplicitDuration_keepsRawDurationForSeekCalculations() {
72+
ConstantBitrateSeeker seeker =
73+
new ConstantBitrateSeeker(
74+
/* inputLength= */ 1_125,
75+
/* firstFramePosition= */ 125,
76+
/* bitrate= */ 8_000,
77+
/* frameSize= */ 1,
78+
/* allowSeeksIfLengthUnknown= */ false,
79+
/* durationUs= */ 900_000);
80+
81+
assertThat(seeker.getDurationUs()).isEqualTo(900_000);
82+
assertThat(seeker.getRawDurationUs()).isEqualTo(1_000_000);
83+
assertThat(seeker.getTimeUs(1_025)).isEqualTo(900_000);
84+
assertThat(seeker.getSeekPoints(800_000).first.position).isEqualTo(925);
85+
assertThat(seeker.getSeekPoints(900_000).first).isEqualTo(new SeekPoint(900_000, 1_124));
86+
}
87+
6988
@Test
7089
public void seeking_handlesSeekToZero() throws IOException {
7190
String fileName = CONSTANT_FRAME_SIZE_TEST_FILE;

libraries/extractor/src/test/java/androidx/media3/extractor/mp3/Mp3ExtractorTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ public void mp3SampleWithInfoHeader(
105105
simulationConfig);
106106
}
107107

108+
@Test
109+
public void mp3SampleWithInfoHeader_usesGaplessDurationAndRawBitrate() throws Exception {
110+
FakeExtractorOutput output =
111+
TestUtil.extractAllSamplesFromFile(
112+
new Mp3Extractor(),
113+
ApplicationProvider.getApplicationContext(),
114+
"media/mp3/test-cbr-info-header.mp3");
115+
116+
assertThat(output.seekMap.getDurationUs()).isEqualTo(999_977);
117+
assertThat(output.trackOutputs.get(0).getDurationUs()).isEqualTo(999_977);
118+
assertThat(output.trackOutputs.get(0).lastFormat.averageBitrate).isEqualTo(64_000);
119+
}
120+
108121
// https://github.com/androidx/media/issues/1376#issuecomment-2117393653
109122
@Test
110123
public void mp3SampleWithInfoHeaderAndPcutFrame(

0 commit comments

Comments
 (0)