Skip to content

Commit 6c562ed

Browse files
committed
Use gapless MP3 Xing and Info durations
1 parent ccb20ca commit 6c562ed

51 files changed

Lines changed: 1327 additions & 966 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/Mp3Extractor.java

Lines changed: 56 additions & 35 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;
@@ -519,12 +542,9 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
519542
resultSeeker.getDataEndPosition());
520543
}
521544

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.
545+
if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
546+
// If resultSeeker does not allow seeking but provides a duration and known end position, use
547+
// this info to do 'enhanced' CBR seeking.
528548
long dataStart =
529549
resultSeeker.getDataStartPosition() != C.INDEX_UNSET
530550
? resultSeeker.getDataStartPosition()
@@ -533,24 +553,26 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
533553
resultSeeker.getDataEndPosition() != C.INDEX_UNSET
534554
? resultSeeker.getDataEndPosition()
535555
: 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)) {
556+
long durationUs = resultSeeker.getDurationUs();
557+
if (durationUs != C.TIME_UNSET && inputLength != C.LENGTH_UNSET) {
558+
int averageBitrate = computeAverageBitrate(inputLength - dataStart, durationUs);
559+
if (averageBitrate != C.RATE_UNSET_INT) {
560+
// Only use enhanced CBR seeking when its bitrate can be derived safely. Otherwise, the
561+
// regular CBR fallback below will use the next frame header bitrate.
562+
// inputLength is known, so we can pass (vacuously) false for allowSeeksIfLengthUnknown.
563+
resultSeeker =
564+
new ConstantBitrateSeeker(
565+
inputLength,
566+
dataStart,
567+
averageBitrate,
568+
C.LENGTH_UNSET,
569+
/* allowSeeksIfLengthUnknown= */ false,
570+
durationUs);
571+
}
572+
}
573+
}
574+
575+
if (shouldFallbackToConstantBitrateSeeking(resultSeeker)) {
554576
// Either we found no seek or VBR info, so we must assume the file is CBR (even without the
555577
// flag(s) being set), or an 'enable CBR seeking flag' is set and we found some seek info, but
556578
// not enough to do 'enhanced' CBR seeking with. In either case, we fall back to CBR seeking
@@ -670,15 +692,13 @@ private Seeker getConstantBitrateSeeker(
670692

671693
// Derive the bitrate and frame size by averaging over the length of playable audio, to allow
672694
// 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));
695+
// See also https://github.com/androidx/media/issues/1376.
696+
int averageBitrate = computeAverageBitrate(audioLength, durationUs);
697+
if (averageBitrate == C.RATE_UNSET_INT) {
698+
// Invalid Info sizes or durations should fall back to the next frame header bitrate rather
699+
// than constructing a ConstantBitrateSeeker with an unset bitrate.
700+
return null;
701+
}
682702
int frameSize =
683703
Ints.checkedCast(LongMath.divide(audioLength, infoFrame.frameCount, RoundingMode.HALF_UP));
684704
// Set the seeker frame size to the average frame size (even though some constant bitrate
@@ -689,7 +709,8 @@ private Seeker getConstantBitrateSeeker(
689709
/* firstFramePosition= */ infoFramePosition + infoFrame.header.frameSize,
690710
averageBitrate,
691711
frameSize,
692-
/* allowSeeksIfLengthUnknown= */ false);
712+
/* allowSeeksIfLengthUnknown= */ false,
713+
durationUs);
693714
}
694715

695716
/**

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;

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
public class IndexSeekerTest {
4141

4242
private static final String TEST_FILE_XING_NO_TOC = "media/mp3/bear-vbr-xing-header-no-toc.mp3";
43-
private static final int TEST_FILE_XING_NO_TOC_DURATION = 2_808_000;
43+
private static final int TEST_FILE_XING_NO_TOC_GAPLESS_DURATION_US = 2_783_979;
4444

4545
private Mp3Extractor extractor;
4646
private FakeExtractorOutput extractorOutput;
@@ -65,14 +65,14 @@ public void mp3ExtractorReads_returnsSeekableSeekMap() throws Exception {
6565
}
6666

6767
@Test
68-
public void mp3ExtractorReads_correctsInexactDuration() throws Exception {
68+
public void mp3ExtractorReads_preservesGaplessDurationAfterEof() throws Exception {
6969
FakeExtractorOutput extractorOutput =
7070
TestUtil.extractAllSamplesFromFile(
7171
extractor, ApplicationProvider.getApplicationContext(), TEST_FILE_XING_NO_TOC);
7272

7373
SeekMap seekMap = extractorOutput.seekMap;
7474

75-
assertThat(seekMap.getDurationUs()).isEqualTo(TEST_FILE_XING_NO_TOC_DURATION);
75+
assertThat(seekMap.getDurationUs()).isEqualTo(TEST_FILE_XING_NO_TOC_GAPLESS_DURATION_US);
7676
}
7777

7878
@Test
@@ -86,6 +86,17 @@ public void constructor_calculatesCorrectAverageBitrate() {
8686
assertThat(seeker.getAverageBitrate()).isEqualTo(8_000);
8787
}
8888

89+
@Test
90+
public void constructor_returnsUnsetAverageBitrateWhenAverageCannotBeCalculated() {
91+
IndexSeeker seeker =
92+
new IndexSeeker(
93+
/* durationUs= */ C.TIME_UNSET,
94+
/* dataStartPosition= */ 100,
95+
/* dataEndPosition= */ C.INDEX_UNSET);
96+
97+
assertThat(seeker.getAverageBitrate()).isEqualTo(C.RATE_UNSET_INT);
98+
}
99+
89100
@Test
90101
public void seeking_handlesSeekToZero() throws Exception {
91102
String fileName = TEST_FILE_XING_NO_TOC;
@@ -111,7 +122,7 @@ public void seeking_handlesSeekToEof() throws Exception {
111122
SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri);
112123
FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
113124

114-
long targetSeekTimeUs = TEST_FILE_XING_NO_TOC_DURATION;
125+
long targetSeekTimeUs = TEST_FILE_XING_NO_TOC_GAPLESS_DURATION_US;
115126
int extractedFrameIndex =
116127
TestUtil.seekToTimeUs(
117128
extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri);

0 commit comments

Comments
 (0)