From 63dc769bffda4422a4f3f516cfd0eb4d4a76992f Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 11 Dec 2015 07:34:23 -0800 Subject: [PATCH] Improve seeking in MP3 files with XING headers. Fix behavior of getTimeUs when seeking after the last entry in the table of contents. Round correctly in getPosition, clipping to the stream duration based on the input length (if known), falling back to the stream size from the header. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=109993852 --- .../extractor/mp3/XingSeekerTest.java | 105 ++++++++++++++++++ .../extractor/mp4/Mp4ExtractorTest.java | 68 +++++------- .../android/exoplayer/testutil/TestUtil.java | 9 ++ .../exoplayer/extractor/mp3/XingSeeker.java | 52 +++++---- 4 files changed, 172 insertions(+), 62 deletions(-) create mode 100644 library/src/androidTest/java/com/google/android/exoplayer/extractor/mp3/XingSeekerTest.java diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp3/XingSeekerTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp3/XingSeekerTest.java new file mode 100644 index 0000000000..ca5051b1ce --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp3/XingSeekerTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer.extractor.mp3; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.testutil.TestUtil; +import com.google.android.exoplayer.util.MpegAudioHeader; +import com.google.android.exoplayer.util.ParsableByteArray; + +import android.test.InstrumentationTestCase; + +/** + * Tests for {@link XingSeeker} + */ +public final class XingSeekerTest extends InstrumentationTestCase { + + // XING header/payload from http://storage.googleapis.com/exoplayer-test-media-0/play.mp3. + private static final int XING_FRAME_HEADER_DATA = 0xFFFB3000; + private static final byte[] XING_FRAME_PAYLOAD = TestUtil.createByteArray( + "00000007000008dd000e7919000205080a0d0f1214171a1c1e212426292c2e303336383b3d404245484a4c4f5254" + + "575a5c5e616466696b6e707376787a7d808285878a8c8f929496999c9ea1a4a6a8abaeb0b3b5b8babdc0c2c4c7" + + "cacccfd2d4d6d9dcdee1e3e6e8ebeef0f2f5f8fafd"); + private static final int XING_FRAME_POSITION = 157; + + /** + * Size of the audio stream, encoded in {@link #XING_FRAME_PAYLOAD}. + */ + private static final int STREAM_SIZE_BYTES = 948505; + /** + * Duration of the audio stream in microseconds, encoded in {@link #XING_FRAME_PAYLOAD}. + */ + private static final int STREAM_DURATION_US = 59271836; + /** + * The length of the file in bytes. + */ + private static final int INPUT_LENGTH = 948662; + + private XingSeeker seeker; + private XingSeeker seekerWithInputLength; + private int xingFrameSize; + + @Override + public void setUp() throws Exception { + MpegAudioHeader xingFrameHeader = new MpegAudioHeader(); + MpegAudioHeader.populateHeader(XING_FRAME_HEADER_DATA, xingFrameHeader); + seeker = XingSeeker.create(xingFrameHeader, new ParsableByteArray(XING_FRAME_PAYLOAD), + XING_FRAME_POSITION, C.UNKNOWN_TIME_US); + seekerWithInputLength = XingSeeker.create(xingFrameHeader, + new ParsableByteArray(XING_FRAME_PAYLOAD), XING_FRAME_POSITION, INPUT_LENGTH); + xingFrameSize = xingFrameHeader.frameSize; + } + + public void testGetTimeUsBeforeFirstAudioFrame() { + assertEquals(0, seeker.getTimeUs(-1)); + assertEquals(0, seekerWithInputLength.getTimeUs(-1)); + } + + public void testGetTimeUsAtFirstAudioFrame() { + assertEquals(0, seeker.getTimeUs(XING_FRAME_POSITION + xingFrameSize)); + assertEquals(0, seekerWithInputLength.getTimeUs(XING_FRAME_POSITION + xingFrameSize)); + } + + public void testGetTimeUsAtEndOfStream() { + assertEquals(STREAM_DURATION_US, + seeker.getTimeUs(XING_FRAME_POSITION + xingFrameSize + STREAM_SIZE_BYTES)); + assertEquals(STREAM_DURATION_US, + seekerWithInputLength.getTimeUs(XING_FRAME_POSITION + xingFrameSize + STREAM_SIZE_BYTES)); + } + + public void testGetPositionAtStartOfStream() { + assertEquals(XING_FRAME_POSITION + xingFrameSize, seeker.getPosition(0)); + assertEquals(XING_FRAME_POSITION + xingFrameSize, seekerWithInputLength.getPosition(0)); + } + + public void testGetPositionAtEndOfStream() { + assertEquals(XING_FRAME_POSITION + STREAM_SIZE_BYTES - 1, + seeker.getPosition(STREAM_DURATION_US)); + assertEquals(XING_FRAME_POSITION + STREAM_SIZE_BYTES - 1, + seekerWithInputLength.getPosition(STREAM_DURATION_US)); + } + + public void testGetTimeForAllPositions() { + for (int offset = xingFrameSize; offset < STREAM_SIZE_BYTES; offset++) { + int position = XING_FRAME_POSITION + offset; + long timeUs = seeker.getTimeUs(position); + assertEquals(position, seeker.getPosition(timeUs)); + timeUs = seekerWithInputLength.getTimeUs(position); + assertEquals(position, seekerWithInputLength.getPosition(timeUs)); + } + } + +} diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp4/Mp4ExtractorTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp4/Mp4ExtractorTest.java index a7a572d3cd..d4f796c3bc 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp4/Mp4ExtractorTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/mp4/Mp4ExtractorTest.java @@ -39,50 +39,51 @@ import java.util.List; public final class Mp4ExtractorTest extends TestCase { /** String of hexadecimal bytes containing the video stsd payload from an AVC video. */ - private static final byte[] VIDEO_STSD_PAYLOAD = getByteArray( - "00000000000000010000009961766331000000000000000100000000000000000000000000000000050002" - + "d00048000000480000000000000001000000000000000000000000000000000000000000000000000000" - + "00000000000018ffff0000002f617663430164001fffe100186764001facb402802dd808800000030080" - + "00001e078c195001000468ee3cb000000014627472740000e35c0042a61000216cb8"); - private static final byte[] VIDEO_HDLR_PAYLOAD = getByteArray("000000000000000076696465"); - private static final byte[] VIDEO_MDHD_PAYLOAD = - getByteArray("0000000000000000cf6c48890000001e00001c8a55c40000"); + private static final byte[] VIDEO_STSD_PAYLOAD = TestUtil.createByteArray( + "00000000000000010000009961766331000000000000000100000000000000000000000000000000050002d00048" + + "000000480000000000000001000000000000000000000000000000000000000000000000000000000000000000" + + "18ffff0000002f617663430164001fffe100186764001facb402802dd80880000003008000001e078c19500100" + + "0468ee3cb000000014627472740000e35c0042a61000216cb8"); + private static final byte[] VIDEO_HDLR_PAYLOAD = TestUtil.createByteArray( + "000000000000000076696465"); + private static final byte[] VIDEO_MDHD_PAYLOAD = TestUtil.createByteArray( + "0000000000000000cf6c48890000001e00001c8a55c40000"); private static final int TIMESCALE = 30; private static final int VIDEO_WIDTH = 1280; private static final int VIDEO_HEIGHT = 720; /** String of hexadecimal bytes containing the video stsd payload for an mp4v track. */ - private static final byte[] VIDEO_STSD_MP4V_PAYLOAD = getByteArray( - "0000000000000001000000A36D703476000000000000000100000000000000000000000000000000014000" - + "B40048000000480000000000000001000000000000000000000000000000000000000000000000000000" - + "00000000000018FFFF0000004D6573647300000000033F00000004372011001A400004CF280002F11805" - + "28000001B001000001B58913000001000000012000C48D8800F50A04169463000001B2476F6F676C6506" - + "0102"); + private static final byte[] VIDEO_STSD_MP4V_PAYLOAD = TestUtil.createByteArray( + "0000000000000001000000A36D703476000000000000000100000000000000000000000000000000014000B40048" + + "000000480000000000000001000000000000000000000000000000000000000000000000000000000000000000" + + "18FFFF0000004D6573647300000000033F00000004372011001A400004CF280002F1180528000001B001000001" + + "B58913000001000000012000C48D8800F50A04169463000001B2476F6F676C65060102"); private static final int VIDEO_MP4V_WIDTH = 320; private static final int VIDEO_MP4V_HEIGHT = 180; /** String of hexadecimal bytes containing the audio stsd payload from an AAC track. */ - private static final byte[] AUDIO_STSD_PAYLOAD = getByteArray( - "0000000000000001000000596d703461000000000000000100000000000000000001001000000000ac4400" - + "000000003565736473000000000327000000041f401500023e00024bc000023280051012080000000000" - + "000000000000000000060102"); - private static final byte[] AUDIO_HDLR_PAYLOAD = getByteArray("0000000000000000736f756e"); - private static final byte[] AUDIO_MDHD_PAYLOAD = - getByteArray("00000000cf6c4889cf6c488a0000ac4400a3e40055c40000"); + private static final byte[] AUDIO_STSD_PAYLOAD = TestUtil.createByteArray( + "0000000000000001000000596d703461000000000000000100000000000000000001001000000000ac4400000000" + + "003565736473000000000327000000041f401500023e00024bc000023280051012080000000000000000000000" + + "000000060102"); + private static final byte[] AUDIO_HDLR_PAYLOAD = TestUtil.createByteArray( + "0000000000000000736f756e"); + private static final byte[] AUDIO_MDHD_PAYLOAD = TestUtil.createByteArray( + "00000000cf6c4889cf6c488a0000ac4400a3e40055c40000"); /** String of hexadecimal bytes for an ftyp payload with major_brand mp41 and minor_version 0. **/ - private static final byte[] FTYP_PAYLOAD = getByteArray("6d70343100000000"); + private static final byte[] FTYP_PAYLOAD = TestUtil.createByteArray("6d70343100000000"); /** String of hexadecimal bytes containing an mvhd payload from an AVC/AAC video. */ - private static final byte[] MVHD_PAYLOAD = getByteArray( - "00000000cf6c4888cf6c48880000025800023ad40001000001000000000000000000000000010000000000" - + "000000000000000000000100000000000000000000000000004000000000000000000000000000000000" - + "000000000000000000000000000003"); + private static final byte[] MVHD_PAYLOAD = TestUtil.createByteArray( + "00000000cf6c4888cf6c48880000025800023ad40001000001000000000000000000000000010000000000000000" + + "000000000000000100000000000000000000000000004000000000000000000000000000000000000000000000" + + "000000000000000003"); /** String of hexadecimal bytes containing a tkhd payload with an unknown duration. */ - private static final byte[] TKHD_PAYLOAD = getByteArray( - "00000007D1F0C7BFD1F0C7BF0000000000000000FFFFFFFF00000000000000000000000000000000000100" - + "0000000000000000000000000000010000000000000000000000000000400000000780000004380000"); + private static final byte[] TKHD_PAYLOAD = TestUtil.createByteArray( + "00000007D1F0C7BFD1F0C7BF0000000000000000FFFFFFFF00000000000000000000000000000000000100000000" + + "0000000000000000000000010000000000000000000000000000400000000780000004380000"); /** Video frame timestamps in time units. */ private static final int[] SAMPLE_TIMESTAMPS = {0, 2, 3, 5, 6, 7}; @@ -450,15 +451,6 @@ public final class Mp4ExtractorTest extends TestCase { return new Mp4Atom(type, payload); } - private static byte[] getByteArray(String hexBytes) { - byte[] result = new byte[hexBytes.length() / 2]; - for (int i = 0; i < result.length; i++) { - result[i] = (byte) ((Character.digit(hexBytes.charAt(i * 2), 16) << 4) - + Character.digit(hexBytes.charAt(i * 2 + 1), 16)); - } - return result; - } - /** * MP4 atom that can be serialized as a byte array. */ diff --git a/library/src/androidTest/java/com/google/android/exoplayer/testutil/TestUtil.java b/library/src/androidTest/java/com/google/android/exoplayer/testutil/TestUtil.java index 77fc5fa12a..073b67dd8a 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/testutil/TestUtil.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/testutil/TestUtil.java @@ -77,6 +77,15 @@ public class TestUtil { return source; } + public static byte[] createByteArray(String hexBytes) { + byte[] result = new byte[hexBytes.length() / 2]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) ((Character.digit(hexBytes.charAt(i * 2), 16) << 4) + + Character.digit(hexBytes.charAt(i * 2 + 1), 16)); + } + return result; + } + public static byte[] createByteArray(int... intArray) { byte[] byteArray = new byte[intArray.length]; for (int i = 0; i < byteArray.length; i++) { diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/XingSeeker.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/XingSeeker.java index bef49dfa89..8a482a3112 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/XingSeeker.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/XingSeeker.java @@ -54,7 +54,7 @@ import com.google.android.exoplayer.util.Util; sampleRate); if ((flags & 0x06) != 0x06) { // If the size in bytes or table of contents is missing, the stream is not seekable. - return new XingSeeker(inputLength, firstFramePosition, durationUs); + return new XingSeeker(firstFramePosition, durationUs, inputLength); } long sizeBytes = frame.readUnsignedIntToInt(); @@ -67,29 +67,32 @@ import com.google.android.exoplayer.util.Util; // TODO: Handle encoder delay and padding in 3 bytes offset by xingBase + 213 bytes: // delay = (frame.readUnsignedByte() << 4) + (frame.readUnsignedByte() >> 4); // padding = ((frame.readUnsignedByte() & 0x0F) << 8) + frame.readUnsignedByte(); - return new XingSeeker(inputLength, firstFramePosition, durationUs, tableOfContents, sizeBytes); + return new XingSeeker(firstFramePosition, durationUs, inputLength, tableOfContents, + sizeBytes, mpegAudioHeader.frameSize); } + private final long firstFramePosition; + private final long durationUs; + private final long inputLength; /** * Entries are in the range [0, 255], but are stored as long integers for convenience. */ private final long[] tableOfContents; - private final long firstFramePosition; private final long sizeBytes; - private final long durationUs; - private final long inputLength; + private final int headerSize; - private XingSeeker(long inputLength, long firstFramePosition, long durationUs) { - this(inputLength, firstFramePosition, durationUs, null, 0); + private XingSeeker(long firstFramePosition, long durationUs, long inputLength) { + this(firstFramePosition, durationUs, inputLength, null, 0, 0); } - private XingSeeker(long inputLength, long firstFramePosition, long durationUs, - long[] tableOfContents, long sizeBytes) { - this.tableOfContents = tableOfContents; + private XingSeeker(long firstFramePosition, long durationUs, long inputLength, + long[] tableOfContents, long sizeBytes, int headerSize) { this.firstFramePosition = firstFramePosition; - this.sizeBytes = sizeBytes; this.durationUs = durationUs; this.inputLength = inputLength; + this.tableOfContents = tableOfContents; + this.sizeBytes = sizeBytes; + this.headerSize = headerSize; } @Override @@ -124,8 +127,10 @@ import com.google.android.exoplayer.util.Util; fx = fa + (fb - fa) * (percent - a); } - long position = (long) ((1.0 / 256) * fx * sizeBytes) + firstFramePosition; - return inputLength != C.LENGTH_UNBOUNDED ? Math.min(position, inputLength - 1) : position; + long position = Math.round((1.0 / 256) * fx * sizeBytes) + firstFramePosition; + long maximumPosition = inputLength != C.LENGTH_UNBOUNDED ? inputLength - 1 + : firstFramePosition - headerSize + sizeBytes - 1; + return Math.min(position, maximumPosition); } @Override @@ -134,16 +139,14 @@ import com.google.android.exoplayer.util.Util; return 0L; } double offsetByte = 256.0 * (position - firstFramePosition) / sizeBytes; - int previousIndex = Util.binarySearchFloor(tableOfContents, (long) offsetByte, true, false); - long previousTime = getTimeUsForTocIndex(previousIndex); - if (previousIndex == 98) { - return previousTime; - } + int previousTocPosition = + Util.binarySearchFloor(tableOfContents, (long) offsetByte, true, false) + 1; + long previousTime = getTimeUsForTocPosition(previousTocPosition); // Linearly interpolate the time taking into account the next entry. - long previousByte = previousIndex == -1 ? 0 : tableOfContents[previousIndex]; - long nextByte = tableOfContents[previousIndex + 1]; - long nextTime = getTimeUsForTocIndex(previousIndex + 1); + long previousByte = previousTocPosition == 0 ? 0 : tableOfContents[previousTocPosition - 1]; + long nextByte = previousTocPosition == 99 ? 256 : tableOfContents[previousTocPosition]; + long nextTime = getTimeUsForTocPosition(previousTocPosition + 1); long timeOffset = nextByte == previousByte ? 0 : (long) ((nextTime - previousTime) * (offsetByte - previousByte) / (nextByte - previousByte)); return previousTime + timeOffset; @@ -155,10 +158,11 @@ import com.google.android.exoplayer.util.Util; } /** - * Returns the time in microseconds corresponding to an index in the table of contents. + * Returns the time in microseconds corresponding to a table of contents position, which is + * interpreted as a percentage of the stream's duration between 0 and 100. */ - private long getTimeUsForTocIndex(int tocIndex) { - return durationUs * (tocIndex + 1) / 100; + private long getTimeUsForTocPosition(int tocPosition) { + return durationUs * tocPosition / 100; } }