Skip to content

Commit 5c77d9b

Browse files
committed
Use gapless MP3 Xing and Info durations
1 parent ccb20ca commit 5c77d9b

52 files changed

Lines changed: 1355 additions & 968 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
`FLAG_READ_MFRA_FOR_SEEK_MAP` to the `FragmentedMp4Extractor`, which is
9393
now done by default in `DefaultExtractorsFactory`
9494
([#3088](https://github.com/androidx/media/issues/3088)).
95+
* MP3: Use gapless-aware durations from Xing/Info headers
96+
([#3183](https://github.com/androidx/media/issues/3183)).
9597
* Ignore `av1C` data with unsupported version.
9698
* MP4: Add support for big-endian floating point PCM in `fpcm` boxes.
9799
* Matroska: Parse chapter info to `Chapter` entries in a track's

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

Lines changed: 39 additions & 7 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,11 +30,15 @@
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
/**
3437
* Constructs an instance.
3538
*
39+
* <p>The duration exposed from {@link #getDurationUs()} is computed from {@code inputLength} and
40+
* the frame bitrate, or is {@link C#TIME_UNSET} if {@code inputLength} is unknown.
41+
*
3642
* @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown.
3743
* @param firstFramePosition The position of the first frame in the stream.
3844
* @param mpegAudioHeader The MPEG audio header associated with the first frame.
@@ -53,23 +59,30 @@ public ConstantBitrateSeeker(
5359
mpegAudioHeader.bitrate,
5460
mpegAudioHeader.frameSize,
5561
allowSeeksIfLengthUnknown,
56-
/* isEstimated= */ true);
62+
/* durationUs= */ C.TIME_UNSET);
5763
}
5864

59-
/** See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. */
65+
/**
66+
* See {@link ConstantBitrateSeekMap#ConstantBitrateSeekMap(long, long, int, int, boolean)}. Uses
67+
* {@code durationUs} as the duration exposed from {@link #getDurationUs()}, or computes the
68+
* duration from {@code inputLength} and {@code bitrate} if {@code durationUs} is {@link
69+
* C#TIME_UNSET}.
70+
*/
6071
public ConstantBitrateSeeker(
6172
long inputLength,
6273
long firstFramePosition,
6374
int bitrate,
6475
int frameSize,
65-
boolean allowSeeksIfLengthUnknown) {
76+
boolean allowSeeksIfLengthUnknown,
77+
long durationUs) {
6678
this(
6779
inputLength,
6880
firstFramePosition,
6981
bitrate,
7082
frameSize,
7183
allowSeeksIfLengthUnknown,
72-
/* isEstimated= */ true);
84+
/* isEstimated= */ true,
85+
durationUs);
7386
}
7487

7588
private ConstantBitrateSeeker(
@@ -78,7 +91,8 @@ private ConstantBitrateSeeker(
7891
int bitrate,
7992
int frameSize,
8093
boolean allowSeeksIfLengthUnknown,
81-
boolean isEstimated) {
94+
boolean isEstimated,
95+
long durationUs) {
8296
super(
8397
inputLength,
8498
firstFramePosition,
@@ -88,8 +102,9 @@ private ConstantBitrateSeeker(
88102
isEstimated);
89103
this.firstFramePosition = firstFramePosition;
90104
this.bitrate = bitrate;
91-
this.frameSize = frameSize;
105+
this.frameSize = frameSize == C.LENGTH_UNSET ? 1 : frameSize;
92106
this.allowSeeksIfLengthUnknown = allowSeeksIfLengthUnknown;
107+
this.durationUs = durationUs;
93108
dataEndPosition = inputLength != C.LENGTH_UNSET ? inputLength : C.INDEX_UNSET;
94109
}
95110

@@ -98,6 +113,17 @@ public long getTimeUs(long position) {
98113
return getTimeUsAtPosition(position);
99114
}
100115

116+
@Override
117+
public SeekPoints getSeekPoints(long timeUs) {
118+
if (durationUs != C.TIME_UNSET && timeUs >= durationUs && dataEndPosition != C.INDEX_UNSET) {
119+
long finalFramePosition = Math.max(firstFramePosition, dataEndPosition - frameSize);
120+
long frameDurationUs = getTimeUsAtPosition(firstFramePosition + frameSize);
121+
return new SeekPoints(
122+
new SeekPoint(Math.max(0, durationUs - frameDurationUs), finalFramePosition));
123+
}
124+
return super.getSeekPoints(timeUs);
125+
}
126+
101127
@Override
102128
public long getDataStartPosition() {
103129
return firstFramePosition;
@@ -108,6 +134,11 @@ public long getDataEndPosition() {
108134
return dataEndPosition;
109135
}
110136

137+
@Override
138+
public long getDurationUs() {
139+
return durationUs != C.TIME_UNSET ? durationUs : super.getDurationUs();
140+
}
141+
111142
@Override
112143
public int getAverageBitrate() {
113144
return bitrate;
@@ -120,6 +151,7 @@ public ConstantBitrateSeeker copyWithNewDataEndPosition(long dataEndPosition) {
120151
bitrate,
121152
frameSize,
122153
allowSeeksIfLengthUnknown,
123-
/* isEstimated= */ false);
154+
/* isEstimated= */ false,
155+
durationUs);
124156
}
125157
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,25 @@
3333
private final IndexSeekMap indexSeekMap;
3434

3535
public IndexSeeker(long durationUs, long dataStartPosition, long dataEndPosition) {
36+
this(
37+
durationUs,
38+
dataStartPosition,
39+
dataEndPosition,
40+
/* fallbackAverageBitrate= */ C.RATE_UNSET_INT);
41+
}
42+
43+
/* package */ IndexSeeker(
44+
long durationUs, long dataStartPosition, long dataEndPosition, int fallbackAverageBitrate) {
3645
this.indexSeekMap =
3746
new IndexSeekMap(
3847
/* positions= */ new long[] {dataStartPosition},
3948
/* timesUs= */ new long[] {0L},
4049
durationUs);
4150
this.dataStartPosition = dataStartPosition;
4251
this.dataEndPosition = dataEndPosition;
43-
this.averageBitrate = computeAverageBitrate(dataEndPosition - dataStartPosition, durationUs);
52+
int averageBitrate = computeAverageBitrate(dataEndPosition - dataStartPosition, durationUs);
53+
this.averageBitrate =
54+
averageBitrate != C.RATE_UNSET_INT ? averageBitrate : fallbackAverageBitrate;
4455
}
4556

4657
@Override

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

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package androidx.media3.extractor.mp3;
1717

18+
import static androidx.media3.extractor.mp3.Mp3Util.computeAverageBitrate;
1819
import static com.google.common.base.Preconditions.checkNotNull;
1920
import static java.lang.annotation.ElementType.TYPE_USE;
2021
import static java.lang.annotation.RetentionPolicy.SOURCE;
@@ -264,7 +265,7 @@ public int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce
264265
int readResult = readInternal(input);
265266
if (readResult == RESULT_END_OF_INPUT && seeker instanceof IndexSeeker) {
266267
// Duration is exact when index seeker is used.
267-
long durationUs = computeTimeUs(samplesRead);
268+
long durationUs = computeFinalIndexSeekerDurationUs(samplesRead);
268269
if (seeker.getDurationUs() != durationUs) {
269270
((IndexSeeker) seeker).setDurationUs(durationUs);
270271
extractorOutput.seekMap(seeker);
@@ -389,6 +390,28 @@ private long computeTimeUs(long samplesRead) {
389390
return basisTimeUs + samplesRead * C.MICROS_PER_SECOND / synchronizedHeader.sampleRate;
390391
}
391392

393+
/**
394+
* Returns the final duration to expose for an {@link IndexSeeker}.
395+
*
396+
* <p>Index seeking finalizes duration from the encoded samples read at EOF. When gapless metadata
397+
* is present, this trims the encoder delay and padding so EOF finalization does not replace an
398+
* initially gapless Xing/Info duration with the longer encoded duration.
399+
*/
400+
private long computeFinalIndexSeekerDurationUs(long samplesRead) {
401+
long durationUs = computeTimeUs(samplesRead);
402+
if (!gaplessInfoHolder.hasGaplessInfo()) {
403+
return durationUs;
404+
}
405+
long finalGaplessSampleIndex =
406+
Util.durationUsToSampleCount(durationUs, synchronizedHeader.sampleRate)
407+
- gaplessInfoHolder.encoderDelay
408+
- gaplessInfoHolder.encoderPadding
409+
- 1;
410+
return finalGaplessSampleIndex >= 0
411+
? Util.sampleCountToDurationUs(finalGaplessSampleIndex, synchronizedHeader.sampleRate)
412+
: C.TIME_UNSET;
413+
}
414+
392415
private boolean synchronize(ExtractorInput input, boolean sniffing) throws IOException {
393416
int validFrameCount = 0;
394417
int candidateSynchronizedHeaderData = 0;
@@ -516,15 +539,13 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
516539
new IndexSeeker(
517540
resultSeeker.getDurationUs(),
518541
/* dataStartPosition= */ input.getPosition(),
519-
resultSeeker.getDataEndPosition());
542+
resultSeeker.getDataEndPosition(),
543+
resultSeeker.getAverageBitrate());
520544
}
521545

522-
if (shouldFallbackToConstantBitrateSeeking(resultSeeker)
523-
&& resultSeeker.getDurationUs() != C.TIME_UNSET
524-
&& (resultSeeker.getDataEndPosition() != C.INDEX_UNSET
525-
|| input.getLength() != C.LENGTH_UNSET)) {
526-
// resultSeeker does not allow seeking, but does provide a duration and constant bitrate
527-
// seeking has been requested, so we can do 'enhanced' CBR seeking using this duration info.
546+
if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
547+
// If resultSeeker does not allow seeking but provides a duration and known end position, use
548+
// this info to do 'enhanced' CBR seeking.
528549
long dataStart =
529550
resultSeeker.getDataStartPosition() != C.INDEX_UNSET
530551
? resultSeeker.getDataStartPosition()
@@ -533,24 +554,26 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
533554
resultSeeker.getDataEndPosition() != C.INDEX_UNSET
534555
? resultSeeker.getDataEndPosition()
535556
: input.getLength();
536-
long audioLength = inputLength - dataStart;
537-
int bitrate =
538-
Ints.saturatedCast(
539-
Util.scaleLargeValue(
540-
audioLength,
541-
Byte.SIZE * C.MICROS_PER_SECOND,
542-
resultSeeker.getDurationUs(),
543-
RoundingMode.HALF_UP));
544-
// inputLength will never be LENGTH_UNSET because of the outer if-condition, so we can pass
545-
// (vacuously) false here for allowSeeksIfLengthUnknown.
546-
resultSeeker =
547-
new ConstantBitrateSeeker(
548-
inputLength,
549-
dataStart,
550-
bitrate,
551-
C.LENGTH_UNSET,
552-
/* allowSeeksIfLengthUnknown= */ false);
553-
} else if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
557+
long durationUs = resultSeeker.getDurationUs();
558+
if (durationUs != C.TIME_UNSET && inputLength != C.LENGTH_UNSET) {
559+
int averageBitrate = computeAverageBitrate(inputLength - dataStart, durationUs);
560+
if (averageBitrate != C.RATE_UNSET_INT) {
561+
// Only use enhanced CBR seeking when its bitrate can be derived safely. Otherwise, the
562+
// regular CBR fallback below will use the next frame header bitrate.
563+
// inputLength is known, so we can pass (vacuously) false for allowSeeksIfLengthUnknown.
564+
resultSeeker =
565+
new ConstantBitrateSeeker(
566+
inputLength,
567+
dataStart,
568+
averageBitrate,
569+
C.LENGTH_UNSET,
570+
/* allowSeeksIfLengthUnknown= */ false,
571+
durationUs);
572+
}
573+
}
574+
}
575+
576+
if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
554577
// Either we found no seek or VBR info, so we must assume the file is CBR (even without the
555578
// flag(s) being set), or an 'enable CBR seeking flag' is set and we found some seek info, but
556579
// not enough to do 'enhanced' CBR seeking with. In either case, we fall back to CBR seeking
@@ -670,15 +693,13 @@ private Seeker getConstantBitrateSeeker(
670693

671694
// Derive the bitrate and frame size by averaging over the length of playable audio, to allow
672695
// for 'mostly' CBR streams that might have a small number of frames with a different bitrate.
673-
// We can assume infoFrame.frameCount is set, because otherwise computeDurationUs() would
674-
// have returned C.TIME_UNSET above. See also https://github.com/androidx/media/issues/1376.
675-
int averageBitrate =
676-
Ints.checkedCast(
677-
Util.scaleLargeValue(
678-
audioLength,
679-
C.BITS_PER_BYTE * C.MICROS_PER_SECOND,
680-
durationUs,
681-
RoundingMode.HALF_UP));
696+
// See also https://github.com/androidx/media/issues/1376.
697+
int averageBitrate = computeAverageBitrate(audioLength, durationUs);
698+
if (averageBitrate == C.RATE_UNSET_INT) {
699+
// Invalid Info sizes or durations should fall back to the next frame header bitrate rather
700+
// than constructing a ConstantBitrateSeeker with an unset bitrate.
701+
return null;
702+
}
682703
int frameSize =
683704
Ints.checkedCast(LongMath.divide(audioLength, infoFrame.frameCount, RoundingMode.HALF_UP));
684705
// Set the seeker frame size to the average frame size (even though some constant bitrate
@@ -689,7 +710,8 @@ private Seeker getConstantBitrateSeeker(
689710
/* firstFramePosition= */ infoFramePosition + infoFrame.header.frameSize,
690711
averageBitrate,
691712
frameSize,
692-
/* allowSeeksIfLengthUnknown= */ false);
713+
/* allowSeeksIfLengthUnknown= */ false,
714+
durationUs);
693715
}
694716

695717
/**

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,18 +141,35 @@ public static XingFrame parse(MpegAudioUtil.Header mpegAudioHeader, ParsableByte
141141

142142
/**
143143
* 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.
144+
* C#TIME_UNSET} if the frame doesn't contain enough information to compute a duration. Encoder
145+
* delay and padding are subtracted if present.
145146
*/
146-
// TODO: b/319235116 - Handle encoder delay and padding when calculating duration.
147147
public long computeDurationUs() {
148+
long sampleCount = getSampleCount();
149+
if (sampleCount == C.LENGTH_UNSET) {
150+
return C.TIME_UNSET;
151+
}
152+
if (encoderDelay != C.LENGTH_UNSET && encoderPadding != C.LENGTH_UNSET) {
153+
sampleCount -= encoderDelay + encoderPadding;
154+
}
155+
if (sampleCount <= 0) {
156+
return C.TIME_UNSET;
157+
}
158+
return computeDurationUs(sampleCount);
159+
}
160+
161+
private long getSampleCount() {
148162
if (frameCount == C.LENGTH_UNSET || frameCount == 0) {
149163
// If the frame count is missing/invalid, the header can't be used to determine the duration.
150-
return C.TIME_UNSET;
164+
return C.LENGTH_UNSET;
151165
}
166+
return frameCount * header.samplesPerFrame;
167+
}
168+
169+
private long computeDurationUs(long sampleCount) {
152170
// Audio requires both a start and end PCM sample, so subtract one from the sample count before
153171
// calculating the duration.
154-
return Util.sampleCountToDurationUs(
155-
(frameCount * header.samplesPerFrame) - 1, header.sampleRate);
172+
return Util.sampleCountToDurationUs(sampleCount - 1, header.sampleRate);
156173
}
157174

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

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

Lines changed: 18 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,23 @@ public void mp3ExtractorReads_returnSeekableCbrSeeker() throws IOException {
6667
assertThat(seekMap.isSeekable()).isTrue();
6768
}
6869

70+
@Test
71+
public void getSeekPoints_atExplicitDuration_returnsFinalFrameSeekPoint() {
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.getTimeUs(1_025)).isEqualTo(900_000);
83+
assertThat(seeker.getSeekPoints(800_000).first.position).isEqualTo(925);
84+
assertThat(seeker.getSeekPoints(900_000).first).isEqualTo(new SeekPoint(899_000, 1_124));
85+
}
86+
6987
@Test
7088
public void seeking_handlesSeekToZero() throws IOException {
7189
String fileName = CONSTANT_FRAME_SIZE_TEST_FILE;

0 commit comments

Comments
 (0)