diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/BufferingInput.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/BufferingInput.java new file mode 100644 index 0000000000..f628b70de3 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/BufferingInput.java @@ -0,0 +1,203 @@ +/* + * 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.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.TrackOutput; +import com.google.android.exoplayer.util.ParsableByteArray; + +import java.io.EOFException; +import java.io.IOException; +import java.nio.BufferOverflowException; + +/** + * Buffers bytes read from an {@link ExtractorInput} to allow re-reading buffered bytes within a + * window starting at a marked position. + */ +/* package */ final class BufferingInput { + + private final ParsableByteArray buffer; + private final int capacity; + + private int readPosition; + private int writePosition; + private int markPosition; + + /** + * Constructs a new buffer for reading from extractor inputs that can store up to {@code capacity} + * bytes. + * + * @param capacity Number of bytes that can be stored in the buffer. + */ + public BufferingInput(int capacity) { + this.capacity = capacity; + buffer = new ParsableByteArray(capacity * 2); + } + + /** Discards any pending data in the buffer and returns the writing position to zero. */ + public void reset() { + readPosition = 0; + writePosition = 0; + markPosition = 0; + } + + /** + * Moves the mark to be at the reading position. Any data before the reading position is + * discarded. After calling this method, calling {@link #returnToMark} will move the reading + * position back to the mark position. + */ + public void mark() { + if (readPosition > capacity) { + System.arraycopy(buffer.data, readPosition, buffer.data, 0, writePosition - readPosition); + writePosition -= readPosition; + readPosition = 0; + } + markPosition = readPosition; + } + + /** Moves the reading position back to the mark position. */ + public void returnToMark() { + readPosition = markPosition; + } + + /** Returns the number of bytes available for reading from the current position. */ + public int getAvailableByteCount() { + return writePosition - readPosition; + } + + /** + * Buffers any more data required to read {@code length} bytes from the reading position, and + * returns a {@link ParsableByteArray} that wraps the buffer's byte array, with its position set + * to the current reading position. The read position is then updated for having read + * {@code length} bytes. + * + * @param extractorInput {@link ExtractorInput} from which additional data should be read. + * @param length Number of bytes that will be readable in the returned array. + * @return {@link ParsableByteArray} from which {@code length} bytes can be read. + * @throws IOException Thrown if there was an error reading from the stream. + * @throws InterruptedException Thrown if reading from the stream was interrupted. + */ + public ParsableByteArray getParsableByteArray(ExtractorInput extractorInput, int length) + throws IOException, InterruptedException { + if (!ensureLoaded(extractorInput, length)) { + throw new EOFException(); + } + ParsableByteArray parsableByteArray = new ParsableByteArray(buffer.data, writePosition); + parsableByteArray.setPosition(readPosition); + readPosition += length; + return parsableByteArray; + } + + /** + * Drains as much buffered data as possible up to {@code length} bytes to {@code trackOutput}. + * + * @param trackOutput Track output to populate with up to {@code length} bytes of sample data. + * @param length Number of bytes to try to read from the buffer. + * @return The number of buffered bytes written. + */ + public int drainToOutput(TrackOutput trackOutput, int length) { + if (length == 0) { + return 0; + } + buffer.setPosition(readPosition); + int bytesToDrain = Math.min(writePosition - readPosition, length); + trackOutput.sampleData(buffer, bytesToDrain); + readPosition += bytesToDrain; + return bytesToDrain; + } + + /** + * Skips {@code length} bytes from the reading position, reading from {@code extractorInput} to + * populate the buffer if required. + * + * @param extractorInput {@link ExtractorInput} from which additional data should be read. + * @param length Number of bytes to skip. + * @throws IOException Thrown if there was an error reading from the stream. + * @throws InterruptedException Thrown if reading from the stream was interrupted. + */ + public void skip(ExtractorInput extractorInput, int length) + throws IOException, InterruptedException { + if (!readInternal(extractorInput, null, 0, length)) { + throw new EOFException(); + } + } + + /** + * Reads {@code length} bytes from the reading position, reading from {@code extractorInput} to + * populate the buffer if required. + * + * @param extractorInput {@link ExtractorInput} from which additional data should be read. + * @param length Number of bytes to read. + * @throws IOException Thrown if there was an error reading from the stream. + * @throws InterruptedException Thrown if reading from the stream was interrupted. + * @throws EOFException Thrown if the end of the file was reached. + */ + public void read(ExtractorInput extractorInput, byte[] target, int offset, int length) + throws IOException, InterruptedException { + if (!readInternal(extractorInput, target, offset, length)) { + throw new EOFException(); + } + } + + /** + * Reads {@code length} bytes from the reading position, reading from {@code extractorInput} to + * populate the buffer if required. + * + *

Returns {@code false} if the end of the stream has been reached. Throws {@link EOFException} + * if the read request could only be partially satisfied. Returns {@code true} otherwise. + * + * @param extractorInput {@link ExtractorInput} from which additional data should be read. + * @param length Number of bytes to read. + * @return Whether the extractor input is at the end of the stream. + * @throws IOException Thrown if there was an error reading from the stream. + * @throws InterruptedException Thrown if reading from the stream was interrupted. + * @throws EOFException Thrown if the end of the file was reached. + */ + public boolean readAllowingEndOfInput(ExtractorInput extractorInput, byte[] target, int offset, + int length) throws IOException, InterruptedException { + return readInternal(extractorInput, target, offset, length); + } + + private boolean readInternal(ExtractorInput extractorInput, byte[] target, int offset, int length) + throws InterruptedException, IOException { + if (!ensureLoaded(extractorInput, length)) { + return false; + } + if (target != null) { + System.arraycopy(buffer.data, readPosition, target, offset, length); + } + readPosition += length; + return true; + } + + /** Ensures the buffer contains enough data to read {@code length} bytes. */ + private boolean ensureLoaded(ExtractorInput extractorInput, int length) + throws InterruptedException, IOException { + if (length + readPosition - markPosition > capacity) { + throw new BufferOverflowException(); + } + + int bytesToLoad = length - (writePosition - readPosition); + if (bytesToLoad > 0) { + if (!extractorInput.readFully(buffer.data, writePosition, bytesToLoad, true)) { + return false; + } + writePosition += bytesToLoad; + } + return true; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/ConstantBitrateSeeker.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/ConstantBitrateSeeker.java new file mode 100644 index 0000000000..cc705819e2 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/ConstantBitrateSeeker.java @@ -0,0 +1,55 @@ +/* + * 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; + +/** + * MP3 seeker that doesn't rely on metadata and seeks assuming the source has a constant bitrate. + */ +/* package */ final class ConstantBitrateSeeker implements Mp3Extractor.Seeker { + + private static final int MICROSECONDS_PER_SECOND = 1000000; + private static final int BITS_PER_BYTE = 8; + + private final long firstFramePosition; + private final int bitrate; + private final long durationUs; + + public ConstantBitrateSeeker(long firstFramePosition, int bitrate, long inputLength) { + this.firstFramePosition = firstFramePosition; + this.bitrate = bitrate; + + durationUs = + inputLength == C.LENGTH_UNBOUNDED ? C.UNKNOWN_TIME_US : getTimeUs(inputLength); + } + + @Override + public long getPosition(long timeUs) { + return firstFramePosition + (timeUs * bitrate) / (MICROSECONDS_PER_SECOND * BITS_PER_BYTE); + } + + @Override + public long getTimeUs(long position) { + return ((position - firstFramePosition) * MICROSECONDS_PER_SECOND * BITS_PER_BYTE) / bitrate; + } + + @Override + public long getDurationUs() { + return durationUs; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java new file mode 100644 index 0000000000..177cbea306 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/Mp3Extractor.java @@ -0,0 +1,289 @@ +/* + * 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.MediaFormat; +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.ExtractorOutput; +import com.google.android.exoplayer.extractor.SeekMap; +import com.google.android.exoplayer.extractor.TrackOutput; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.ParsableByteArray; +import com.google.android.exoplayer.util.Util; + +import java.io.EOFException; +import java.io.IOException; +import java.util.Collections; + +/** + * Extracts data from an MP3 file. + */ +public final class Mp3Extractor implements Extractor { + + /** + * {@link SeekMap} that also allows mapping from position (byte offset) back to time, which can be + * used to work out the new sample basis timestamp after seeking and resynchronization. + */ + /* package */ interface Seeker extends SeekMap { + + /** + * Maps a position (byte offset) to a corresponding sample timestamp. + * + * @param position A seek position (byte offset) relative to the start of the stream. + * @return The corresponding timestamp of the next sample to be read, in microseconds. + */ + long getTimeUs(long position); + + /** Returns the duration of the source, in microseconds. */ + long getDurationUs(); + + } + + /** The maximum number of bytes to search when synchronizing, before giving up. */ + private static final int MAX_BYTES_TO_SEARCH = 128 * 1024; + + /** Mask that includes the audio header values that must match between frames. */ + private static final int HEADER_MASK = 0xFFFE0C00; + private static final int ID3_TAG = Util.getIntegerCodeForString("ID3"); + private static final String[] MIME_TYPE_BY_LAYER = + new String[] {MimeTypes.AUDIO_MPEG_L1, MimeTypes.AUDIO_MPEG_L2, MimeTypes.AUDIO_MPEG}; + + /** + * Theoretical maximum frame size for an MPEG audio stream, which occurs when playing a Layer 2 + * MPEG 2.5 audio stream at 16 kb/s (with padding). The size is 1152 sample/frame * + * 160000 bit/s / (8000 sample/s * 8 bit/byte) + 1 padding byte/frame = 2881 byte/frame. + * The next power of two size is 4 KiB. + */ + private static final int MAX_FRAME_SIZE_BYTES = 4096; + + private final BufferingInput inputBuffer; + private final ParsableByteArray scratch; + private final MpegAudioHeader synchronizedHeader; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; + + private int synchronizedHeaderData; + + private Seeker seeker; + private long basisTimeUs; + private int samplesRead; + private int sampleBytesRemaining; + + /** Constructs a new {@link Mp3Extractor}. */ + public Mp3Extractor() { + inputBuffer = new BufferingInput(MAX_FRAME_SIZE_BYTES * 3); + scratch = new ParsableByteArray(4); + synchronizedHeader = new MpegAudioHeader(); + } + + @Override + public void init(ExtractorOutput extractorOutput) { + this.extractorOutput = extractorOutput; + trackOutput = extractorOutput.track(0); + extractorOutput.endTracks(); + } + + @Override + public void seek() { + synchronizedHeaderData = 0; + samplesRead = 0; + basisTimeUs = -1; + sampleBytesRemaining = 0; + inputBuffer.reset(); + } + + @Override + public int read(ExtractorInput extractorInput) throws IOException, InterruptedException { + if (synchronizedHeaderData == 0 + && synchronizeCatchingEndOfInput(extractorInput) == RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + + return readSample(extractorInput); + } + + private int readSample(ExtractorInput extractorInput) throws IOException, InterruptedException { + if (sampleBytesRemaining == 0) { + long headerPosition = maybeResynchronize(extractorInput); + if (headerPosition == RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + if (basisTimeUs == -1) { + basisTimeUs = seeker.getTimeUs(getPosition(extractorInput, inputBuffer)); + } + sampleBytesRemaining = synchronizedHeader.frameSize; + } + + long timeUs = basisTimeUs + (samplesRead * 1000000L / synchronizedHeader.sampleRate); + + // Start by draining any buffered bytes, then read directly from the extractor input. + sampleBytesRemaining -= inputBuffer.drainToOutput(trackOutput, sampleBytesRemaining); + if (sampleBytesRemaining > 0) { + inputBuffer.mark(); + + // Return if we still need more data. + sampleBytesRemaining -= trackOutput.sampleData(extractorInput, sampleBytesRemaining); + if (sampleBytesRemaining > 0) { + return RESULT_CONTINUE; + } + } + + trackOutput.sampleMetadata(timeUs, C.SAMPLE_FLAG_SYNC, synchronizedHeader.frameSize, 0, null); + samplesRead += synchronizedHeader.samplesPerFrame; + sampleBytesRemaining = 0; + return RESULT_CONTINUE; + } + + /** Attempts to read an MPEG audio header at the current offset, resynchronizing if necessary. */ + private long maybeResynchronize(ExtractorInput extractorInput) + throws IOException, InterruptedException { + inputBuffer.mark(); + if (!inputBuffer.readAllowingEndOfInput(extractorInput, scratch.data, 0, 4)) { + return RESULT_END_OF_INPUT; + } + inputBuffer.returnToMark(); + + scratch.setPosition(0); + int sampleHeaderData = scratch.readInt(); + if ((sampleHeaderData & HEADER_MASK) == (synchronizedHeaderData & HEADER_MASK)) { + int frameSize = MpegAudioHeader.getFrameSize(sampleHeaderData); + if (frameSize != -1) { + MpegAudioHeader.populateHeader(sampleHeaderData, synchronizedHeader); + return RESULT_CONTINUE; + } + } + + synchronizedHeaderData = 0; + inputBuffer.skip(extractorInput, 1); + return synchronizeCatchingEndOfInput(extractorInput); + } + + private long synchronizeCatchingEndOfInput(ExtractorInput extractorInput) + throws IOException, InterruptedException { + // An EOFException will be raised if any read operation was partially satisfied. If a seek + // operation resulted in reading from within the last frame, we may try to read past the end of + // the file in a partially-satisfied read operation, so we need to catch the exception. + try { + return synchronize(extractorInput); + } catch (EOFException e) { + return RESULT_END_OF_INPUT; + } + } + + private long synchronize(ExtractorInput extractorInput) throws IOException, InterruptedException { + long startPosition = getPosition(extractorInput, inputBuffer); + + // Skip any ID3 header at the start of the file. + if (startPosition == 0) { + inputBuffer.read(extractorInput, scratch.data, 0, 3); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() == ID3_TAG) { + extractorInput.skipFully(3); + extractorInput.readFully(scratch.data, 0, 4); + int headerLength = ((scratch.data[0] & 0x7F) << 21) | ((scratch.data[1] & 0x7F) << 14) + | ((scratch.data[2] & 0x7F) << 7) | (scratch.data[3] & 0x7F); + extractorInput.skipFully(headerLength); + inputBuffer.reset(); + startPosition = getPosition(extractorInput, inputBuffer); + } else { + inputBuffer.returnToMark(); + } + } + + // Try to find four consecutive valid MPEG audio frames. + inputBuffer.mark(); + long headerPosition = startPosition; + int validFrameCount = 0; + while (true) { + if (headerPosition - startPosition >= MAX_BYTES_TO_SEARCH) { + throw new ParserException("Searched too many bytes while resynchronizing."); + } + + if (!inputBuffer.readAllowingEndOfInput(extractorInput, scratch.data, 0, 4)) { + return RESULT_END_OF_INPUT; + } + + scratch.setPosition(0); + int headerData = scratch.readInt(); + int frameSize; + if ((synchronizedHeaderData != 0 + && (headerData & HEADER_MASK) != (synchronizedHeaderData & HEADER_MASK)) + || (frameSize = MpegAudioHeader.getFrameSize(headerData)) == -1) { + validFrameCount = 0; + synchronizedHeaderData = 0; + + // Try reading a header starting at the next byte. + inputBuffer.returnToMark(); + inputBuffer.skip(extractorInput, 1); + inputBuffer.mark(); + headerPosition++; + continue; + } + + if (validFrameCount == 0) { + MpegAudioHeader.populateHeader(headerData, synchronizedHeader); + synchronizedHeaderData = headerData; + } + + // The header was valid and matching (if appropriate). Check another or end synchronization. + validFrameCount++; + if (validFrameCount == 4) { + break; + } + + // Look for more headers. + inputBuffer.skip(extractorInput, frameSize - 4); + } + + // The input buffer read position is now synchronized. + inputBuffer.returnToMark(); + if (seeker == null) { + ParsableByteArray frame = + inputBuffer.getParsableByteArray(extractorInput, synchronizedHeader.frameSize); + seeker = XingSeeker.create(synchronizedHeader, frame, headerPosition, + extractorInput.getLength()); + if (seeker == null) { + seeker = VbriSeeker.create(synchronizedHeader, frame, headerPosition); + } + if (seeker == null) { + inputBuffer.returnToMark(); + seeker = new ConstantBitrateSeeker( + headerPosition, synchronizedHeader.bitrate * 1000, extractorInput.getLength()); + } else { + // Discard the frame that was parsed for seeking metadata. + inputBuffer.mark(); + } + extractorOutput.seekMap(seeker); + trackOutput.format(MediaFormat.createAudioFormat( + MIME_TYPE_BY_LAYER[synchronizedHeader.layerIndex], MAX_FRAME_SIZE_BYTES, + seeker.getDurationUs(), synchronizedHeader.channels, synchronizedHeader.sampleRate, + synchronizedHeader.bitrate * 1000, Collections.emptyList())); + } + + return headerPosition; + } + + /** Returns the reading position of {@code bufferingInput} relative to the extractor's stream. */ + private static long getPosition(ExtractorInput extractorInput, BufferingInput bufferingInput) { + return extractorInput.getPosition() - bufferingInput.getAvailableByteCount(); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/MpegAudioHeader.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/MpegAudioHeader.java new file mode 100644 index 0000000000..9f05d0e3a6 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/MpegAudioHeader.java @@ -0,0 +1,189 @@ +/* + * 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; + +/** Parsed MPEG audio frame header. */ +/* package */ final class MpegAudioHeader { + + private static final int[] SAMPLING_RATE_V1 = {44100, 48000, 32000}; + private static final int[] BITRATE_V1_L1 = + {32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448}; + private static final int[] BITRATE_V2_L1 = + {32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256}; + private static final int[] BITRATE_V1_L2 = + {32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384}; + private static final int[] BITRATE_V1_L3 = + {32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320}; + private static final int[] BITRATE_V2 = + {8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160}; + + /** Returns the size of the frame associated with {@code header}, or -1 if it is invalid. */ + public static int getFrameSize(int header) { + if ((header & 0xFFE00000) != 0xFFE00000) { + return -1; + } + + int version = (header >>> 19) & 3; + if (version == 1) { + return -1; + } + + int layer = (header >>> 17) & 3; + if (layer == 0) { + return -1; + } + + int bitrateIndex = (header >>> 12) & 15; + if (bitrateIndex == 0 || bitrateIndex == 0xF) { + // Disallow "free" bitrate. + return -1; + } + + int samplingRateIndex = (header >>> 10) & 3; + if (samplingRateIndex == 3) { + return -1; + } + + int samplingRate = SAMPLING_RATE_V1[samplingRateIndex]; + if (version == 2) { + // Version 2 + samplingRate /= 2; + } else if (version == 0) { + // Version 2.5 + samplingRate /= 4; + } + + int bitrate; + int padding = (header >>> 9) & 1; + if (layer == 3) { + // Layer I (layer == 3) + bitrate = version == 3 ? BITRATE_V1_L1[bitrateIndex - 1] : BITRATE_V2_L1[bitrateIndex - 1]; + return (12000 * bitrate / samplingRate + padding) * 4; + } else { + // Layer II (layer == 2) or III (layer == 1) + if (version == 3) { + bitrate = layer == 2 ? BITRATE_V1_L2[bitrateIndex - 1] : BITRATE_V1_L3[bitrateIndex - 1]; + } else { + // Version 2 or 2.5. + bitrate = BITRATE_V2[bitrateIndex - 1]; + } + } + + if (version == 3) { + // Version 1 + return 144000 * bitrate / samplingRate + padding; + } else { + // Version 2 or 2.5 + return (layer == 1 ? 72000 : 144000) * bitrate / samplingRate + padding; + } + } + + /** + * Returns the header represented by {@code header}, if it is valid; {@code null} otherwise. + * + * @param headerData Header data to parse. + * @param header Header to populate with data from {@code headerData}. + */ + public static void populateHeader(int headerData, MpegAudioHeader header) { + if ((headerData & 0xFFE00000) != 0xFFE00000) { + return; + } + + int version = (headerData >>> 19) & 3; + if (version == 1) { + return; + } + + int layer = (headerData >>> 17) & 3; + if (layer == 0) { + return; + } + + int bitrateIndex = (headerData >>> 12) & 15; + if (bitrateIndex == 0 || bitrateIndex == 0xF) { + // Disallow "free" bitrate. + return; + } + + int samplingRateIndex = (headerData >>> 10) & 3; + if (samplingRateIndex == 3) { + return; + } + + int sampleRate = SAMPLING_RATE_V1[samplingRateIndex]; + if (version == 2) { + // Version 2 + sampleRate /= 2; + } else if (version == 0) { + // Version 2.5 + sampleRate /= 4; + } + + int padding = (headerData >>> 9) & 1; + int bitrate, frameSize, samplesPerFrame; + if (layer == 3) { + // Layer I (layer == 3) + bitrate = version == 3 ? BITRATE_V1_L1[bitrateIndex - 1] : BITRATE_V2_L1[bitrateIndex - 1]; + frameSize = (12000 * bitrate / sampleRate + padding) * 4; + samplesPerFrame = 384; + } else { + // Layer II (layer == 2) or III (layer == 1) + if (version == 3) { + // Version 1 + bitrate = layer == 2 ? BITRATE_V1_L2[bitrateIndex - 1] : BITRATE_V1_L3[bitrateIndex - 1]; + samplesPerFrame = 1152; + frameSize = 144000 * bitrate / sampleRate + padding; + } else { + // Version 2 or 2.5. + bitrate = BITRATE_V2[bitrateIndex - 1]; + samplesPerFrame = layer == 1 ? 576 : 1152; + frameSize = (layer == 1 ? 72000 : 144000) * bitrate / sampleRate + padding; + } + } + + int channels = ((headerData >> 6) & 3) == 3 ? 1 : 2; + int layerIndex = 3 - layer; + header.setValues( + version, layerIndex, frameSize, sampleRate, channels, bitrate, samplesPerFrame); + } + + /** MPEG audio header version. */ + public int version; + /** MPEG audio layer index, starting at zero. */ + public int layerIndex; + /** Size of the frame associated with this header, in bytes. */ + public int frameSize; + /** Sample rate in samples per second. */ + public int sampleRate; + /** Number of audio channels in the frame. */ + public int channels; + /** Bitrate of the frame in kbit/s. */ + public int bitrate; + /** Number of samples stored in the frame. */ + public int samplesPerFrame; + + private void setValues(int version, int layerIndex, int frameSize, int sampleRate, int channels, + int bitrate, int samplesPerFrame) { + this.version = version; + this.layerIndex = layerIndex; + this.frameSize = frameSize; + this.sampleRate = sampleRate; + this.channels = channels; + this.bitrate = bitrate; + this.samplesPerFrame = samplesPerFrame; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/mp3/VbriSeeker.java b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/VbriSeeker.java new file mode 100644 index 0000000000..cf4d89cf8e --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/VbriSeeker.java @@ -0,0 +1,118 @@ +/* + * 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.util.ParsableByteArray; +import com.google.android.exoplayer.util.Util; + +/** + * MP3 seeker that uses metadata from a VBRI header. + */ +/* package */ final class VbriSeeker implements Mp3Extractor.Seeker { + + private static final int VBRI_HEADER = Util.getIntegerCodeForString("VBRI"); + + /** + * If {@code frame} contains a VBRI header and it is usable for seeking, returns a + * {@link Mp3Extractor.Seeker} for seeking in the containing stream. Otherwise, returns + * {@code null}, which indicates that the information in the frame was not a VBRI header, or was + * unusable for seeking. + */ + public static VbriSeeker create( + MpegAudioHeader mpegAudioHeader, ParsableByteArray frame, long position) { + long basePosition = position + mpegAudioHeader.frameSize; + + // Read the VBRI header. + frame.skip(32); + int headerData = frame.readInt(); + if (headerData != VBRI_HEADER) { + return null; + } + frame.skip(10); + int numFrames = frame.readInt(); + if (numFrames <= 0) { + return null; + } + int sampleRate = mpegAudioHeader.sampleRate; + long durationUs = Util.scaleLargeTimestamp( + numFrames, 1000000L * (sampleRate >= 32000 ? 1152 : 576), sampleRate); + int numEntries = frame.readUnsignedShort(); + int scale = frame.readUnsignedShort(); + int entrySize = frame.readUnsignedShort(); + + // Read entries in the VBRI header. + long[] timesUs = new long[numEntries]; + long[] offsets = new long[numEntries]; + long segmentDurationUs = durationUs / numEntries; + long now = 0; + int segmentIndex = 0; + while (segmentIndex < numEntries) { + int numBytes; + switch (entrySize) { + case 1: + numBytes = frame.readUnsignedByte(); + break; + case 2: + numBytes = frame.readUnsignedShort(); + break; + case 3: + numBytes = frame.readUnsignedInt24(); + break; + case 4: + numBytes = frame.readUnsignedIntToInt(); + break; + default: + return null; + } + now += segmentDurationUs; + timesUs[segmentIndex] = now; + position += numBytes * scale; + offsets[segmentIndex] = position; + + segmentIndex++; + } + return new VbriSeeker(timesUs, offsets, basePosition, durationUs); + } + + private final long[] timesUs; + private final long[] positions; + private final long basePosition; + private final long durationUs; + + private VbriSeeker(long[] timesUs, long[] positions, long basePosition, long durationUs) { + this.timesUs = timesUs; + this.positions = positions; + this.basePosition = basePosition; + this.durationUs = durationUs; + } + + @Override + public long getPosition(long timeUs) { + int index = Util.binarySearchFloor(timesUs, timeUs, false, false); + return basePosition + (index == -1 ? 0L : positions[index]); + } + + @Override + public long getTimeUs(long position) { + return timesUs[Util.binarySearchFloor(positions, position, true, true)]; + } + + @Override + public long getDurationUs() { + return durationUs; + } + +} 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 new file mode 100644 index 0000000000..a499ffb0a0 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/mp3/XingSeeker.java @@ -0,0 +1,167 @@ +/* + * 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.util.ParsableByteArray; +import com.google.android.exoplayer.util.Util; + +/** + * MP3 seeker that uses metadata from a XING header. + */ +/* package */ final class XingSeeker implements Mp3Extractor.Seeker { + + private static final int XING_HEADER = Util.getIntegerCodeForString("Xing"); + private static final int INFO_HEADER = Util.getIntegerCodeForString("Info"); + + /** + * If {@code frame} contains a XING header and it is usable for seeking, returns a + * {@link Mp3Extractor.Seeker} for seeking in the containing stream. Otherwise, returns + * {@code null}, which indicates that the information in the frame was not a XING header, or was + * unusable for seeking. + */ + public static XingSeeker create(MpegAudioHeader mpegAudioHeader, ParsableByteArray frame, + long position, long inputLength) { + int samplesPerFrame = mpegAudioHeader.samplesPerFrame; + int sampleRate = mpegAudioHeader.sampleRate; + long firstFramePosition = position + mpegAudioHeader.frameSize; + + // Skip to the XING header. + int xingBase; + if ((mpegAudioHeader.version & 1) == 1) { + // MPEG 1. + if (mpegAudioHeader.channels != 1) { + xingBase = 32; + } else { + xingBase = 17; + } + } else { + // MPEG 2 or 2.5. + if (mpegAudioHeader.channels != 1) { + xingBase = 17; + } else { + xingBase = 9; + } + } + frame.skip(4 + xingBase); + int headerData = frame.readInt(); + if (headerData != XING_HEADER && headerData != INFO_HEADER) { + return null; + } + + int flags = frame.readInt(); + // Frame count, size and table of contents are required to use this header. + if ((flags & 0x07) != 0x07) { + return null; + } + + // Read frame count, as (flags & 1) == 1. + int frameCount = frame.readUnsignedIntToInt(); + if (frameCount == 0) { + return null; + } + long durationUs = + Util.scaleLargeTimestamp(frameCount, samplesPerFrame * 1000000L, sampleRate); + + // Read size in bytes, as (flags & 2) == 2. + long sizeBytes = frame.readUnsignedIntToInt(); + + // Read table-of-contents as (flags & 4) == 4. + frame.skip(1); + long[] tableOfContents = new long[99]; + for (int i = 0; i < 99; i++) { + tableOfContents[i] = frame.readUnsignedByte(); + } + + // TODO: Handle encoder delay and padding in 3 bytes offset by xingBase + 213 bytes: + // delay = ((frame.readUnsignedByte() & 0xFF) << 4) + ((frame.readUnsignedByte() & 0xFF) >>> 4); + // padding = ((frame.readUnsignedByte() & 0x0F) << 8) + (frame.readUnsignedByte() & 0xFF); + return new XingSeeker(tableOfContents, firstFramePosition, sizeBytes, durationUs, 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 XingSeeker(long[] tableOfContents, long firstFramePosition, long sizeBytes, + long durationUs, long inputLength) { + this.tableOfContents = tableOfContents; + this.firstFramePosition = firstFramePosition; + this.sizeBytes = sizeBytes; + this.durationUs = durationUs; + this.inputLength = inputLength; + } + + @Override + public long getPosition(long timeUs) { + float percent = timeUs * 100f / durationUs; + float fx; + if (percent <= 0f) { + fx = 0f; + } else if (percent >= 100f) { + fx = 256f; + } else { + int a = (int) percent; + float fa, fb; + if (a == 0) { + fa = 0f; + } else { + fa = tableOfContents[a - 1]; + } + if (a < 99) { + fb = tableOfContents[a]; + } else { + fb = 256f; + } + fx = fa + (fb - fa) * (percent - a); + } + + long position = (long) ((1f / 256) * fx * sizeBytes) + firstFramePosition; + return inputLength != C.LENGTH_UNBOUNDED ? Math.min(position, inputLength) : position; + } + + @Override + public long getTimeUs(long position) { + long offsetByte = 256 * (position - firstFramePosition) / sizeBytes; + int previousIndex = Util.binarySearchFloor(tableOfContents, offsetByte, true, false); + long previousTime = getTimeUsForTocIndex(previousIndex); + if (previousIndex == 98) { + return previousTime; + } + + // 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 timeOffset = + (nextTime - previousTime) * (offsetByte - previousByte) / (nextByte - previousByte); + return previousTime + timeOffset; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + /** Returns the time in microseconds corresponding to an index in the table of contents. */ + private long getTimeUsForTocIndex(int tocIndex) { + return durationUs * (tocIndex + 1) / 100; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/Util.java b/library/src/main/java/com/google/android/exoplayer/util/Util.java index 6160427890..9c8db64a90 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/Util.java +++ b/library/src/main/java/com/google/android/exoplayer/util/Util.java @@ -460,4 +460,19 @@ public final class Util { } } + /** + * Returns the integer equal to the big-endian concatenation of the characters in {@code string} + * as bytes. {@code string} must contain four or fewer characters. + */ + public static int getIntegerCodeForString(String string) { + int length = string.length(); + Assertions.checkArgument(length <= 4); + int result = 0; + for (int i = 0; i < length; i++) { + result <<= 8; + result |= string.charAt(i); + } + return result; + } + } diff --git a/library/src/test/java/com/google/android/exoplayer/extractor/mp3/BufferingInputTest.java b/library/src/test/java/com/google/android/exoplayer/extractor/mp3/BufferingInputTest.java new file mode 100644 index 0000000000..d6668f94c6 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/extractor/mp3/BufferingInputTest.java @@ -0,0 +1,223 @@ +/* + * 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 static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +import com.google.android.exoplayer.extractor.DefaultExtractorInput; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.TrackOutput; +import com.google.android.exoplayer.testutil.FakeDataSource; +import com.google.android.exoplayer.testutil.Util; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.util.ParsableByteArray; + +import android.net.Uri; +import android.test.InstrumentationTestCase; + +import org.mockito.Mock; + +import java.nio.BufferOverflowException; +import java.util.Arrays; + +/** + * Tests for {@link BufferingInput}. + */ +public class BufferingInputTest extends InstrumentationTestCase { + + private static final String TEST_URI = "http://www.google.com"; + private static final byte[] STREAM_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + private ExtractorInput fakeExtractorInput; + + /** Used for verifying interactions. */ + @Mock private ExtractorInput mockExtractorInput; + @Mock private TrackOutput mockTrackOutput; + + @Override + public void setUp() throws Exception { + Util.setUpMockito(this); + + FakeDataSource.Builder builder = new FakeDataSource.Builder(); + builder.appendReadData(STREAM_DATA); + FakeDataSource fakeDataSource = builder.build(); + fakeDataSource.open(new DataSpec(Uri.parse(TEST_URI))); + fakeExtractorInput = new DefaultExtractorInput(fakeDataSource, 0, STREAM_DATA.length); + } + + public void testReadFromExtractor() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[4]; + input.read(fakeExtractorInput, target, 0, 4); + assertMatchesStreamData(target, 0, 4); + } + + public void testReadCapacityFromExtractor() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[5]; + input.read(fakeExtractorInput, target, 0, 5); + assertMatchesStreamData(target, 0, 5); + } + + public void testReadOverCapacityFromExtractorFails() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[6]; + try { + input.read(fakeExtractorInput, target, 0, 6); + fail(); + } catch (BufferOverflowException e) { + // Expected. + } + } + + public void testReadFromBuffer() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[5]; + input.read(fakeExtractorInput, target, 0, 5); + + // When reading already-buffered data + input.returnToMark(); + input.read(mockExtractorInput, target, 0, 5); + assertMatchesStreamData(target, 0, 5); + + // There is no interaction with the extractor input. + verifyZeroInteractions(mockExtractorInput); + } + + public void testReadFromBufferPartially() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[5]; + input.read(fakeExtractorInput, target, 0, 5); + + // When reading already-buffered data + input.returnToMark(); + input.read(mockExtractorInput, target, 0, 4); + assertMatchesStreamData(target, 0, 4); + + // There is no interaction with the extractor input. + verifyZeroInteractions(mockExtractorInput); + } + + public void testResetDiscardsData() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[5]; + input.read(fakeExtractorInput, target, 0, 5); + + // When the buffer is reset + input.reset(); + + // Then it is possible to read up to the capacity again. + input.read(fakeExtractorInput, target, 0, 5); + assertMatchesStreamData(target, 5, 5); + } + + public void testGetAvailableByteCountAtWritePosition() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[5]; + input.read(fakeExtractorInput, target, 0, 5); + assertEquals(0, input.getAvailableByteCount()); + } + + public void testGetAvailableByteCountBeforeWritePosition() throws Exception { + BufferingInput input = new BufferingInput(5); + byte[] target = new byte[5]; + input.read(fakeExtractorInput, target, 0, 3); + input.mark(); + input.read(fakeExtractorInput, target, 0, 3); + input.mark(); + input.read(fakeExtractorInput, target, 0, 2); + input.returnToMark(); + + // The reading position is calculated correctly. + assertEquals(2, input.getAvailableByteCount()); + assertEquals(8, fakeExtractorInput.getPosition()); + } + + public void testGetParsableByteArray() throws Exception { + BufferingInput input = new BufferingInput(5); + input.skip(fakeExtractorInput, 4); + input.mark(); + input.skip(fakeExtractorInput, 3); + input.returnToMark(); + ParsableByteArray parsableByteArray = input.getParsableByteArray(fakeExtractorInput, 4); + + // The returned array matches the input's internal buffer. + assertMatchesStreamData(parsableByteArray.data, 0, 7); + } + + public void testGetParsableByteArrayPastCapacity() throws Exception { + BufferingInput input = new BufferingInput(5); + input.skip(fakeExtractorInput, 4); + input.mark(); + input.skip(fakeExtractorInput, 3); + input.mark(); + input.skip(fakeExtractorInput, 1); + input.returnToMark(); + ParsableByteArray parsableByteArray = input.getParsableByteArray(fakeExtractorInput, 2); + + // The second call to mark() copied the buffer data to the start. + assertMatchesStreamData(parsableByteArray.data, 7, 2); + } + + public void testDrainEntireBuffer() throws Exception { + BufferingInput input = new BufferingInput(5); + input.skip(fakeExtractorInput, 3); + input.returnToMark(); + + // When draining the first three bytes + input.drainToOutput(mockTrackOutput, 3); + + // They are appended as sample data. + verify(mockTrackOutput).sampleData(any(ParsableByteArray.class), eq(3)); + } + + public void testDrainTwice() throws Exception { + BufferingInput input = new BufferingInput(5); + input.skip(fakeExtractorInput, 3); + input.returnToMark(); + + // When draining one then two bytes + input.drainToOutput(mockTrackOutput, 1); + assertEquals(2, input.drainToOutput(mockTrackOutput, 3)); + + // They are appended as sample data. + verify(mockTrackOutput).sampleData(any(ParsableByteArray.class), eq(1)); + verify(mockTrackOutput).sampleData(any(ParsableByteArray.class), eq(2)); + } + + public void testDrainPastCapacity() throws Exception { + BufferingInput input = new BufferingInput(5); + input.skip(fakeExtractorInput, 4); + input.mark(); + input.skip(fakeExtractorInput, 5); + input.returnToMark(); + + // When draining the entire buffer + input.drainToOutput(mockTrackOutput, 5); + + // The sample data is appended as one whole buffer. + verify(mockTrackOutput).sampleData(any(ParsableByteArray.class), eq(5)); + } + + private static void assertMatchesStreamData(byte[] read, int offset, int length) { + assertTrue(Arrays.equals(Arrays.copyOfRange(STREAM_DATA, offset, offset + length), + Arrays.copyOfRange(read, 0, length))); + } + +} diff --git a/library/src/test/java/com/google/android/exoplayer/testutil/Util.java b/library/src/test/java/com/google/android/exoplayer/testutil/Util.java index 5f86b51829..4d2897e7a4 100644 --- a/library/src/test/java/com/google/android/exoplayer/testutil/Util.java +++ b/library/src/test/java/com/google/android/exoplayer/testutil/Util.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer.testutil; +import android.test.InstrumentationTestCase; + +import org.mockito.MockitoAnnotations; + import java.util.Random; /** @@ -35,4 +39,11 @@ public class Util { return source; } + public static void setUpMockito(InstrumentationTestCase instrumentationTestCase) { + // Workaround for https://code.google.com/p/dexmaker/issues/detail?id=2. + System.setProperty("dexmaker.dexcache", + instrumentationTestCase.getInstrumentation().getTargetContext().getCacheDir().getPath()); + MockitoAnnotations.initMocks(instrumentationTestCase); + } + }