diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java b/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java index 4af33e9326..b0df73f94a 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/SampleChooserActivity.java @@ -76,8 +76,7 @@ public class SampleChooserActivity extends Activity { group.addAll(Samples.MISC); sampleGroups.add(group); group = new SampleGroup("Extensions"); - group.addAll(Samples.VP9_EXTENSION_SAMPLES); - group.addAll(Samples.VP9_OPUS_EXTENSION_SAMPLES); + group.addAll(Samples.EXTENSION); sampleGroups.add(group); ExpandableListView sampleList = (ExpandableListView) findViewById(R.id.sample_list); diff --git a/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java b/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java index 1537bea5ed..c60beb7465 100644 --- a/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java +++ b/demo/src/main/java/com/google/android/exoplayer/demo/Samples.java @@ -262,16 +262,13 @@ import java.util.Locale; "http://vod.leasewebcdn.com/bbb.flv?ri=1024&rs=150&start=0", Util.TYPE_OTHER), }; - public static final Sample[] VP9_EXTENSION_SAMPLES = new Sample[] { + public static final Sample[] EXTENSION = new Sample[] { new Sample("Google Glass DASH - VP9 Only", "http://demos.webmproject.org/dash/201410/vp9_glass/manifest_vp9.mpd", Util.TYPE_DASH, true), new Sample("Google Glass DASH - VP9 and Vorbis", "http://demos.webmproject.org/dash/201410/vp9_glass/manifest_vp9_vorbis.mpd", Util.TYPE_DASH, true), - }; - - public static final Sample[] VP9_OPUS_EXTENSION_SAMPLES = new Sample[] { new Sample("Google Glass DASH - VP9 and Opus", "http://demos.webmproject.org/dash/201410/vp9_glass/manifest_vp9_opus.mpd", Util.TYPE_DASH, true), diff --git a/library/src/androidTest/assets/ogg/bear.opus b/library/src/androidTest/assets/ogg/bear.opus new file mode 100644 index 0000000000..4808533b3d Binary files /dev/null and b/library/src/androidTest/assets/ogg/bear.opus differ diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggExtractorTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggExtractorTest.java index f75ffc08cf..0913d456aa 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggExtractorTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OggExtractorTest.java @@ -38,17 +38,17 @@ public final class OggExtractorTest extends TestCase { public void testSniffVorbis() throws Exception { byte[] data = TestUtil.joinByteArrays( - TestData.buildOggHeader(0x02, 0, 1000, 0x02), - TestUtil.createByteArray(120, 120), // Laces + TestData.buildOggHeader(0x02, 0, 1000, 1), + TestUtil.createByteArray(7), // Laces new byte[]{0x01, 'v', 'o', 'r', 'b', 'i', 's'}); assertTrue(sniff(createInput(data))); } public void testSniffFlac() throws Exception { byte[] data = TestUtil.joinByteArrays( - TestData.buildOggHeader(0x02, 0, 1000, 0x02), - TestUtil.createByteArray(120, 120), // Laces - new byte[]{0x7F, 'F', 'L', 'A', 'C', ' ', ' '}); + TestData.buildOggHeader(0x02, 0, 1000, 1), + TestUtil.createByteArray(5), // Laces + new byte[]{0x7F, 'F', 'L', 'A', 'C'}); assertTrue(sniff(createInput(data))); } @@ -66,8 +66,8 @@ public final class OggExtractorTest extends TestCase { public void testSniffInvalidHeader() throws Exception { byte[] data = TestUtil.joinByteArrays( - TestData.buildOggHeader(0x02, 0, 1000, 0x02), - TestUtil.createByteArray(120, 120), // Laces + TestData.buildOggHeader(0x02, 0, 1000, 1), + TestUtil.createByteArray(7), // Laces new byte[]{0x7F, 'X', 'o', 'r', 'b', 'i', 's'}); assertFalse(sniff(createInput(data))); } diff --git a/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OpusReaderTest.java b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OpusReaderTest.java new file mode 100644 index 0000000000..a0cfe9a00e --- /dev/null +++ b/library/src/androidTest/java/com/google/android/exoplayer/extractor/ogg/OpusReaderTest.java @@ -0,0 +1,107 @@ +/* + * 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.ogg; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.Format; +import com.google.android.exoplayer.extractor.DefaultExtractorInput; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.PositionHolder; +import com.google.android.exoplayer.testutil.FakeExtractorOutput; +import com.google.android.exoplayer.testutil.FakeTrackOutput; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DataSpec; +import com.google.android.exoplayer.upstream.DefaultDataSource; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.Util; + +import android.content.Context; +import android.net.Uri; +import android.test.InstrumentationTestCase; + +import java.io.IOException; + +/** + * Unit test for {@link OpusReader}. + */ +public final class OpusReaderTest extends InstrumentationTestCase { + + private static final String TEST_FILE = "asset:///ogg/bear.opus"; + + private OggExtractor extractor; + private OpusReader opusReader; + private FakeExtractorOutput extractorOutput; + private DefaultExtractorInput extractorInput; + + @Override + public void setUp() throws Exception { + super.setUp(); + + Context context = getInstrumentation().getContext(); + DataSource dataSource = new DefaultDataSource(context, null, Util + .getUserAgent(context, "ExoPlayerExtFlacTest"), false); + Uri uri = Uri.parse(TEST_FILE); + long length = dataSource.open(new DataSpec(uri, 0, C.LENGTH_UNBOUNDED, null)); + extractorInput = new DefaultExtractorInput(dataSource, 0, length); + + extractor = new OggExtractor(); + assertTrue(extractor.sniff(extractorInput)); + extractorInput.resetPeekPosition(); + + opusReader = (OpusReader) extractor.getStreamReader(); + + extractorOutput = new FakeExtractorOutput(); + extractor.init(extractorOutput); + } + + public void testSniffOpus() throws Exception { + // Do nothing. All assertions are in setUp() + } + + public void testParseHeader() throws Exception { + FakeTrackOutput trackOutput = parseFile(false); + + trackOutput.assertSampleCount(0); + + Format format = trackOutput.format; + assertNotNull(format); + assertEquals(MimeTypes.AUDIO_OPUS, format.sampleMimeType); + assertEquals(48000, format.sampleRate); + assertEquals(2, format.channelCount); + } + + public void testParseWholeFile() throws Exception { + FakeTrackOutput trackOutput = parseFile(true); + + trackOutput.assertSampleCount(275); + } + + private FakeTrackOutput parseFile(boolean parseAll) throws IOException, InterruptedException { + PositionHolder seekPositionHolder = new PositionHolder(); + int readResult = Extractor.RESULT_CONTINUE; + do { + readResult = extractor.read(extractorInput, seekPositionHolder); + if (readResult == Extractor.RESULT_SEEK) { + fail("There should be no seek"); + } + } while (readResult != Extractor.RESULT_END_OF_INPUT && parseAll); + + assertEquals(1, extractorOutput.trackOutputs.size()); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + assertNotNull(trackOutput); + return trackOutput; + } +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/FlacReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/FlacReader.java index 3d9b4848c4..30793e2488 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/FlacReader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/FlacReader.java @@ -46,8 +46,8 @@ import java.util.List; private boolean firstAudioPacketProcessed; - /* package */ static boolean verifyBitstreamType(ParsableByteArray data) { - return data.readUnsignedByte() == 0x7F && // packet type + public static boolean verifyBitstreamType(ParsableByteArray data) { + return data.bytesLeft() >= 5 && data.readUnsignedByte() == 0x7F && // packet type data.readUnsignedInt() == 0x464C4143; // ASCII signature "FLAC" } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggExtractor.java index f41aa0bbc8..6593af3bfa 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggExtractor.java @@ -38,20 +38,19 @@ public class OggExtractor implements Extractor { ParsableByteArray scratch = new ParsableByteArray(new byte[OggUtil.PAGE_HEADER_SIZE], 0); OggUtil.PageHeader header = new OggUtil.PageHeader(); if (!OggUtil.populatePageHeader(input, header, scratch, true) - || (header.type & 0x02) != 0x02 || header.bodySize < 7) { + || (header.type & 0x02) != 0x02) { return false; } - scratch.reset(); - input.peekFully(scratch.data, 0, 7); - if (FlacReader.verifyBitstreamType(scratch)) { + input.peekFully(scratch.data, 0, header.bodySize); + scratch.setLimit(header.bodySize); + if (FlacReader.verifyBitstreamType(resetPosition(scratch))) { streamReader = new FlacReader(); + } else if (VorbisReader.verifyBitstreamType(resetPosition(scratch))) { + streamReader = new VorbisReader(); + } else if (OpusReader.verifyBitstreamType(resetPosition(scratch))) { + streamReader = new OpusReader(); } else { - scratch.reset(); - if (VorbisReader.verifyBitstreamType(scratch)) { - streamReader = new VorbisReader(); - } else { - return false; - } + return false; } return true; } catch (ParserException e) { @@ -83,4 +82,15 @@ public class OggExtractor implements Extractor { throws IOException, InterruptedException { return streamReader.read(input, seekPosition); } + + //@VisibleForTesting + /* package */ StreamReader getStreamReader() { + return streamReader; + } + + private static ParsableByteArray resetPosition(ParsableByteArray scratch) { + scratch.setPosition(0); + return scratch; + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java index c17d4b61f3..ba7f7ba4ef 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OggUtil.java @@ -87,7 +87,7 @@ import java.io.IOException; * as argument. * * @param input the {@link ExtractorInput} to read from. - * @param header the {@link PageHeader} to read from. + * @param header the {@link PageHeader} to be populated. * @param scratch a scratch array temporary use. Its size should be at least PAGE_HEADER_SIZE * @param quite if {@code true} no Exceptions are thrown but {@code false} is return if something * goes wrong. diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OpusReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OpusReader.java new file mode 100644 index 0000000000..f2f620a3a4 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/OpusReader.java @@ -0,0 +1,125 @@ +/* + * 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.ogg; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.Format; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.PositionHolder; +import com.google.android.exoplayer.extractor.SeekMap; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.ParsableByteArray; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * {@link StreamReader} to extract Opus data out of Ogg byte stream. + */ +/* package */ final class OpusReader extends StreamReader { + + /** + * Opus streams are always decoded at 48000 Hz. + */ + private static final int SAMPLE_RATE = 48000; + + private static final byte[] OPUS_SIGNATURE = {'O', 'p', 'u', 's', 'H', 'e', 'a', 'd'}; + + private static final int STATE_READ_HEADER = 0; + private static final int STATE_READ_TAGS = 1; + private static final int STATE_READ_AUDIO = 2; + + private int state = STATE_READ_HEADER; + private long timeUs; + + public static boolean verifyBitstreamType(ParsableByteArray data) { + if (data.bytesLeft() < OPUS_SIGNATURE.length) { + return false; + } + byte[] header = new byte[OPUS_SIGNATURE.length]; + data.readBytes(header, 0, OPUS_SIGNATURE.length); + return Arrays.equals(header, OPUS_SIGNATURE); + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + if (!oggParser.readPacket(input, scratch)) { + return Extractor.RESULT_END_OF_INPUT; + } + + byte[] data = scratch.data; + int dataSize = scratch.limit(); + + switch (state) { + case STATE_READ_HEADER: { + byte[] metadata = Arrays.copyOfRange(data, 0, dataSize); + int channelCount = metadata[9] & 0xFF; + List initializationData = Collections.singletonList(metadata); + trackOutput.format(Format.createAudioSampleFormat(null, MimeTypes.AUDIO_OPUS, + Format.NO_VALUE, Format.NO_VALUE, channelCount, SAMPLE_RATE, + initializationData, null, null)); + state = STATE_READ_TAGS; + } break; + case STATE_READ_TAGS: + // skip this packet + state = STATE_READ_AUDIO; + extractorOutput.seekMap(new SeekMap.Unseekable(C.UNSET_TIME_US)); + break; + case STATE_READ_AUDIO: + trackOutput.sampleData(scratch, dataSize); + trackOutput.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, dataSize, 0, null); + timeUs += getPacketDuration(data); + break; + } + + scratch.reset(); + return Extractor.RESULT_CONTINUE; + } + + private long getPacketDuration(byte[] packet) { + int toc = packet[0] & 0xFF; + int frames; + switch (toc & 0x3) { + case 0: + frames = 1; + break; + case 1: + case 2: + frames = 2; + break; + default: + frames = packet[1] & 0x3F; + break; + } + + int config = toc >> 3; + int length = config & 0x3; + if (config >= 16) { + length = 2500 << length; + } else if (config >= 12) { + length = 10000 << (length & 0x1); + } else if (length == 3) { + length = 60000; + } else { + length = 10000 << length; + } + return frames * length; + } +} diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisReader.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisReader.java index 5bac358dee..8fd8b77565 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisReader.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisReader.java @@ -51,7 +51,7 @@ import java.util.ArrayList; private long totalSamples; private long durationUs; - /* package */ static boolean verifyBitstreamType(ParsableByteArray data) { + public static boolean verifyBitstreamType(ParsableByteArray data) { try { return VorbisUtil.verifyVorbisHeaderCapturePattern(0x01, data, true); } catch (ParserException e) { diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java index a325e4e03e..e392bac7ae 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ogg/VorbisUtil.java @@ -121,15 +121,23 @@ import java.util.Arrays; * * @param headerType the type of the header expected. * @param header the alleged header bytes. - * @param quite if {@code true} no exceptions are thrown. Instead {@code false} is returned. + * @param quiet if {@code true} no exceptions are thrown. Instead {@code false} is returned. * @return the number of bytes read. * @throws ParserException thrown if header type or capture pattern is not as expected. */ public static boolean verifyVorbisHeaderCapturePattern(int headerType, ParsableByteArray header, - boolean quite) + boolean quiet) throws ParserException { + if (header.bytesLeft() < 7) { + if (quiet) { + return false; + } else { + throw new ParserException("too short header: " + header.bytesLeft()); + } + } + if (header.readUnsignedByte() != headerType) { - if (quite) { + if (quiet) { return false; } else { throw new ParserException("expected header type " + Integer.toHexString(headerType)); @@ -142,7 +150,7 @@ import java.util.Arrays; && header.readUnsignedByte() == 'b' && header.readUnsignedByte() == 'i' && header.readUnsignedByte() == 's')) { - if (quite) { + if (quiet) { return false; } else { throw new ParserException("expected characters 'vorbis'");