diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorSampleSource.java b/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorSampleSource.java index c816192464..da06ee5d75 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorSampleSource.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ExtractorSampleSource.java @@ -57,9 +57,10 @@ import java.util.List; *
  • Ogg Vorbis ({@link com.google.android.exoplayer.extractor.ogg.OggVorbisExtractor}
  • *
  • MP3 ({@link com.google.android.exoplayer.extractor.mp3.Mp3Extractor})
  • *
  • AAC ({@link com.google.android.exoplayer.extractor.ts.AdtsExtractor})
  • - *
  • MPEG TS ({@link com.google.android.exoplayer.extractor.ts.TsExtractor}
  • - *
  • MPEG PS ({@link com.google.android.exoplayer.extractor.ts.PsExtractor}
  • - *
  • FLV ({@link com.google.android.exoplayer.extractor.flv.FlvExtractor}
  • + *
  • MPEG TS ({@link com.google.android.exoplayer.extractor.ts.TsExtractor})
  • + *
  • MPEG PS ({@link com.google.android.exoplayer.extractor.ts.PsExtractor})
  • + *
  • FLV ({@link com.google.android.exoplayer.extractor.flv.FlvExtractor})
  • + *
  • WAV ({@link com.google.android.exoplayer.extractor.wav.WavExtractor})
  • * * *

    Seeking in AAC, MPEG TS and FLV streams is not supported. @@ -183,6 +184,13 @@ public final class ExtractorSampleSource implements SampleSource, ExtractorOutpu } catch (ClassNotFoundException e) { // Extractor not found. } + try { + DEFAULT_EXTRACTOR_CLASSES.add( + Class.forName("com.google.android.exoplayer.extractor.wav.WavExtractor") + .asSubclass(Extractor.class)); + } catch (ClassNotFoundException e) { + // Extractor not found. + } } private final ExtractorHolder extractorHolder; diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavExtractor.java new file mode 100644 index 0000000000..d42c7ffa07 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavExtractor.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2016 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.wav; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.Format; +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.PositionHolder; +import com.google.android.exoplayer.extractor.SeekMap; +import com.google.android.exoplayer.extractor.TrackOutput; +import com.google.android.exoplayer.util.MimeTypes; + +import java.io.IOException; + +/** {@link Extractor} to extract samples from a WAV byte stream. */ +public final class WavExtractor implements Extractor, SeekMap { + + /** Arbitrary maximum input size of 32KB, which is ~170ms of 16-bit stereo PCM audio at 48KHz. */ + private static final int MAX_INPUT_SIZE = 32 * 1024; + + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; + private WavHeader wavHeader; + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return WavHeaderReader.peek(input) != null; + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + trackOutput = output.track(0); + wavHeader = null; + output.endTracks(); + } + + @Override + public void seek() { + // Do nothing. + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + if (wavHeader == null) { + wavHeader = WavHeaderReader.peek(input); + if (wavHeader == null) { + // Should only happen if the media wasn't sniffed. + throw new ParserException("Unsupported or unrecognized wav header."); + } + Format format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_RAW, + wavHeader.getBitrate(), MAX_INPUT_SIZE, wavHeader.getNumChannels(), + wavHeader.getSampleRateHz(), null, null); + trackOutput.format(format); + } + + if (!wavHeader.hasDataBounds()) { + WavHeaderReader.skipToData(input, wavHeader); + extractorOutput.seekMap(this); + } + + long inputPosition = input.getPosition(); + int bytesRead = trackOutput.sampleData(input, MAX_INPUT_SIZE, true); + if (bytesRead == RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + + trackOutput.sampleMetadata( + wavHeader.getTimeUs(inputPosition), C.SAMPLE_FLAG_SYNC, bytesRead, 0, null); + return RESULT_CONTINUE; + } + + // SeekMap implementation. + + @Override + public long getDurationUs() { + return wavHeader.getDurationUs(); + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public long getPosition(long timeUs) { + return wavHeader.getPosition(timeUs); + } +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavHeader.java b/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavHeader.java new file mode 100644 index 0000000000..5e0d1afeb6 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavHeader.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2016 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.wav; + +import com.google.android.exoplayer.C; + +/** Header for a WAV file. */ +/*package*/ final class WavHeader { + + /** Number of audio chanels. */ + private final int numChannels; + /** Sample rate in Hertz. */ + private final int sampleRateHz; + /** Average bytes per second for the sample data. */ + private final int averageBytesPerSecond; + /** Alignment for frames of audio data; should equal {@code numChannels * bitsPerSample / 8}. */ + private final int blockAlignment; + /** Bits per sample for the audio data. */ + private final int bitsPerSample; + /** Offset to the start of sample data. */ + private long dataStartPosition; + /** Total size in bytes of the sample data. */ + private long dataSize; + + public WavHeader( + int numChannels, + int sampleRateHz, + int averageBytesPerSecond, + int blockAlignment, + int bitsPerSample) { + this.numChannels = numChannels; + this.sampleRateHz = sampleRateHz; + this.averageBytesPerSecond = averageBytesPerSecond; + this.blockAlignment = blockAlignment; + this.bitsPerSample = bitsPerSample; + } + + /** Returns the duration in microseconds of this WAV. */ + public long getDurationUs() { + return (getNumFrames() * C.MICROS_PER_SECOND) / sampleRateHz; + } + + /** Returns the number of samples in this WAV. */ + public long getNumSamples() { + return dataSize / getBytesPerSample(); + } + + /** Returns the number of frames in this WAV. */ + public long getNumFrames() { + return getNumSamples() / getNumChannels(); + } + + /** Returns the bytes per sample of this WAV. */ + public int getBytesPerSample() { + return blockAlignment / numChannels; + } + + /** Returns the bitrate of this WAV. */ + public int getBitrate() { + return sampleRateHz * bitsPerSample * numChannels; + } + + /** Returns the sample rate in Hertz of this WAV. */ + public int getSampleRateHz() { + return sampleRateHz; + } + + /** Returns the number of audio channels in this WAV. */ + public int getNumChannels() { + return numChannels; + } + + /** Returns the position in bytes in this WAV for the given time in microseconds. */ + public long getPosition(long timeUs) { + long unroundedPosition = (timeUs * averageBytesPerSecond) / C.MICROS_PER_SECOND; + // Round down to nearest frame. + return (unroundedPosition / numChannels) * numChannels + dataStartPosition; + } + + /** Returns the time in microseconds for the given position in bytes in this WAV. */ + public long getTimeUs(long position) { + return position * C.MICROS_PER_SECOND / averageBytesPerSecond; + } + + /** Returns true if the data start position and size have been set. */ + public boolean hasDataBounds() { + return dataStartPosition != 0 && dataSize != 0; + } + + /** Sets the start position and size in bytes of sample data in this WAV. */ + public void setDataBounds(long dataStartPosition, long dataSize) { + this.dataStartPosition = dataStartPosition; + this.dataSize = dataSize; + } +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavHeaderReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavHeaderReader.java new file mode 100644 index 0000000000..7f15d89398 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/wav/WavHeaderReader.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2016 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.wav; + +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.ParsableByteArray; +import com.google.android.exoplayer.util.Util; + +import android.util.Log; + +import java.io.IOException; + +/** Reads a {@code WavHeader} from an input stream; supports resuming from input failures. */ +/*package*/ final class WavHeaderReader { + + private static final String TAG = "WavHeaderReader"; + + /** Integer PCM audio data. */ + private static final int TYPE_PCM = 0x0001; + /** Extended WAVE format. */ + private static final int TYPE_WAVE_FORMAT_EXTENSIBLE = 0xFFFE; + + /** + * Peeks and returns a {@code WavHeader}. + * + * @param input Input stream to peek the WAV header from. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + * @throws ParserException If the input file is an incorrect RIFF WAV. + * @return A new {@code WavHeader} peeked from {@code input}, or null if the input is not a + * supported WAV format. + */ + public static WavHeader peek(ExtractorInput input) + throws IOException, InterruptedException, ParserException { + Assertions.checkNotNull(input); + + // Allocate a scratch buffer large enough to store the format chunk. + ParsableByteArray scratch = new ParsableByteArray(16); + + // Attempt to read the RIFF chunk. + ChunkHeader riffChunkHeader = ChunkHeader.peek(input, scratch); + if (riffChunkHeader.id != Util.getIntegerCodeForString("RIFF")) { + return null; + } + + input.peekFully(scratch.data, 0, 4); + scratch.setPosition(0); + int riffFormat = scratch.readInt(); + if (riffFormat != Util.getIntegerCodeForString("WAVE")) { + Log.e(TAG, "Unsupported RIFF format: " + riffFormat); + return null; + } + + // Attempt to read the format chunk. + ChunkHeader formatChunkHeader = ChunkHeader.peek(input, scratch); + if (formatChunkHeader.id != Util.getIntegerCodeForString("fmt ")) { + throw new ParserException( + "Second chunk in RIFF WAV should be format; got: " + formatChunkHeader.id); + } + + input.peekFully(scratch.data, 0, 16); + scratch.setPosition(0); + int type = scratch.readLittleEndianUnsignedShort(); + int numChannels = scratch.readLittleEndianUnsignedShort(); + int sampleRateHz = scratch.readLittleEndianUnsignedIntToInt(); + int averageBytesPerSecond = scratch.readLittleEndianUnsignedIntToInt(); + int blockAlignment = scratch.readLittleEndianUnsignedShort(); + int bitsPerSample = scratch.readLittleEndianUnsignedShort(); + + int expectedBlockAlignment = numChannels * bitsPerSample / 8; + if (blockAlignment != expectedBlockAlignment) { + throw new ParserException( + "Expected WAV block alignment of: " + + expectedBlockAlignment + + "; got: " + + blockAlignment); + } + if (bitsPerSample != 16) { + Log.e(TAG, "Only 16-bit WAVs are supported; got: " + bitsPerSample); + return null; + } + + if (type == TYPE_PCM) { + Assertions.checkState(formatChunkHeader.size == 16); + // No more data to read. + } else if (type == TYPE_WAVE_FORMAT_EXTENSIBLE) { + Assertions.checkState(formatChunkHeader.size == 40); + // Skip extensionSize, validBitsPerSample, channelMask, subFormatGuid. + input.advancePeekPosition(2 + 2 + 4 + 16); + } else { + Log.e(TAG, "Unsupported WAV format type: " + type); + return null; + } + + return new WavHeader( + numChannels, sampleRateHz, averageBytesPerSecond, blockAlignment, bitsPerSample); + } + + /** + * Skips to the data in the given WAV input stream and returns its data size. After calling, the + * input stream's position will point to the start of sample data in the WAV. + *

    + * If an exception is thrown, the input position will be left pointing to a chunk header. + * + * @param input Input stream to skip to the data chunk in. Its peek position must be pointing to + * a valid chunk header that is not the RIFF chunk. + * @param wavHeader WAV header to populate with data bounds. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If interrupted while reading from input. + * @throws ParserException If an error occurs parsing chunks. + */ + public static void skipToData(ExtractorInput input, WavHeader wavHeader) + throws IOException, InterruptedException, ParserException { + Assertions.checkNotNull(input); + Assertions.checkNotNull(wavHeader); + + ParsableByteArray scratch = new ParsableByteArray(ChunkHeader.SIZE_IN_BYTES); + // Skip all chunks until we hit the data header. + ChunkHeader chunkHeader = ChunkHeader.peek(input, scratch); + while (chunkHeader.id != Util.getIntegerCodeForString("data")) { + Log.w(TAG, "Ignoring unknown WAV chunk: " + chunkHeader.id); + long bytesToSkip = ChunkHeader.SIZE_IN_BYTES + chunkHeader.size; + if (bytesToSkip > Integer.MAX_VALUE) { + throw new ParserException("Chunk is too large (~2GB+) to skip; id: " + chunkHeader.id); + } + input.skipFully((int) bytesToSkip); + chunkHeader = ChunkHeader.peek(input, scratch); + } + // Skip past the "data" header. + input.skipFully(ChunkHeader.SIZE_IN_BYTES); + + wavHeader.setDataBounds(input.getPosition(), chunkHeader.size); + } + + /** Container for a WAV chunk header. */ + private static final class ChunkHeader { + + /** Size in bytes of a WAV chunk header. */ + public static final int SIZE_IN_BYTES = 8; + + /** 4-character identifier, stored as an integer, for this chunk. */ + public final int id; + /** Size of this chunk in bytes. */ + public final long size; + + private ChunkHeader(int id, long size) { + this.id = id; + this.size = size; + } + + /** + * Peeks and returns a {@link ChunkHeader}. + * + * @param input Input stream to peek the chunk header from. + * @param scratch Buffer for temporary use. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + * @return A new {@code ChunkHeader} peeked from {@code input}. + */ + public static ChunkHeader peek(ExtractorInput input, ParsableByteArray scratch) + throws IOException, InterruptedException { + input.peekFully(scratch.data, 0, SIZE_IN_BYTES); + scratch.setPosition(0); + + int id = scratch.readInt(); + long size = scratch.readLittleEndianUnsignedInt(); + + return new ChunkHeader(id, size); + } + } +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java b/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java index 8beff5f9cb..f54de76066 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java +++ b/library/src/main/java/com/google/android/exoplayer/util/ParsableByteArray.java @@ -194,6 +194,13 @@ public final class ParsableByteArray { | (data[position++] & 0xFF); } + /** Reads the next three bytes as a signed value in little endian order. */ + public int readLittleEndianInt24() { + return (data[position++] & 0xFF) + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF) << 16; + } + /** Reads the next three bytes as an unsigned value in little endian order. */ public int readLittleEndianUnsignedInt24() { return (data[position++] & 0xFF) @@ -294,6 +301,20 @@ public final class ParsableByteArray { return result; } + /** + * Reads the next four bytes as a little endian unsigned integer into an integer, if the top bit + * is a zero. + * + * @throws IllegalStateException Thrown if the top bit of the input data is set. + */ + public int readLittleEndianUnsignedIntToInt() { + int result = readLittleEndianInt(); + if (result < 0) { + throw new IllegalStateException("Top bit not zero: " + result); + } + return result; + } + /** * Reads the next eight bytes as an unsigned long into a long, if the top bit is a zero. *