From b93611dca306a4ed0508396d86cb6c9db3f073b1 Mon Sep 17 00:00:00 2001 From: Ryan Heise Date: Thu, 26 Dec 2019 00:44:08 +1100 Subject: [PATCH] Time stretching, AndroidX, update gradle --- LICENSE | 208 ++++ android/build.gradle | 12 +- android/gradle.properties | 4 +- .../com/ryanheise/just_audio/AudioPlayer.java | 576 +++++++-- .../ryanheise/just_audio/JustAudioPlugin.java | 4 +- android/src/main/java/sonic/Sonic.java | 1081 +++++++++++++++++ example/.gitignore | 1 + example/android/app/build.gradle | 8 +- example/android/build.gradle | 2 +- example/android/gradle.properties | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/lib/main.dart | 39 +- example/pubspec.lock | 18 +- example/pubspec.yaml | 3 +- ios/Classes/AudioPlayer.m | 13 +- ios/Classes/JustAudioPlugin.m | 35 +- lib/just_audio.dart | 50 +- pubspec.lock | 61 +- pubspec.yaml | 2 +- 19 files changed, 1963 insertions(+), 160 deletions(-) create mode 100644 android/src/main/java/sonic/Sonic.java diff --git a/LICENSE b/LICENSE index ac5f5b7..50f883b 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,211 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +============================================================================== + +This software includes the sonic library which is licensed under the Apache +License, Version 2.0. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/android/build.gradle b/android/build.gradle index d7c7f4b..99d1426 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' + classpath 'com.android.tools.build:gradle:3.5.0' } } @@ -25,10 +25,16 @@ android { compileSdkVersion 28 defaultConfig { - minSdkVersion 16 - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + minSdkVersion 21 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + lintOptions { disable 'InvalidPackage' } + + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } } diff --git a/android/gradle.properties b/android/gradle.properties index 2bd6f4f..38c8d45 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,4 @@ org.gradle.jvmargs=-Xmx1536M - +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java b/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java index 086a39a..1cb5f6c 100644 --- a/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java +++ b/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java @@ -1,26 +1,35 @@ package com.ryanheise.just_audio; -import android.media.MediaPlayer; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; import android.media.MediaTimestamp; +import android.os.Handler; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel.EventSink; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; -import java.util.List; -import java.io.IOException; -import android.os.Handler; -import java.util.ArrayList; import io.flutter.plugin.common.PluginRegistry.Registrar; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.LinkedList; +import java.util.List; +import sonic.Sonic; -public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionListener { +public class AudioPlayer implements MethodCallHandler { private final Registrar registrar; private final MethodChannel methodChannel; private final EventChannel eventChannel; private EventSink eventSink; private final Handler handler = new Handler(); - private Runnable endDetector; private final Runnable positionObserver = new Runnable() { @Override public void run() { @@ -35,14 +44,31 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL }; private final String id; - private final MediaPlayer player; - private PlaybackState state; + private String url; + private volatile PlaybackState state; private PlaybackState stateBeforeSeek; private long updateTime; private int updatePosition; - private Integer seekPos; + private Deque seekRequests = new LinkedList<>(); + + private MediaExtractor extractor; + private MediaFormat format; + private Sonic sonic; + private int channelCount; + private int sampleRate; + private int duration; + private MediaCodec codec; + private AudioTrack audioTrack; + private PlayThread playThread; + private int start; + private Integer untilPosition; + private Object monitor = new Object(); + private float volume = 1.0f; + private float speed = 1.0f; + private Thread mainThread; public AudioPlayer(final Registrar registrar, final String id) { + mainThread = Thread.currentThread(); this.registrar = registrar; this.id = id; methodChannel = new MethodChannel(registrar.messenger(), "com.ryanheise.just_audio.methods." + id); @@ -60,34 +86,25 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL } }); state = PlaybackState.none; - player = new MediaPlayer(); - player.setOnCompletionListener(this); } private void checkForDiscontinuity() { - // TODO: Consider using player.setOnMediaTimeDiscontinuityListener() - // when available in SDK. (Added in API level 28) final long now = System.currentTimeMillis(); final int position = getCurrentPosition(); final long timeSinceLastUpdate = now - updateTime; - final long expectedPosition = updatePosition + timeSinceLastUpdate; + final long expectedPosition = updatePosition + (long)(timeSinceLastUpdate * speed); final long drift = position - expectedPosition; // Update if we've drifted or just started observing if (updateTime == 0L) { broadcastPlayerState(); } else if (drift < -100) { System.out.println("time discontinuity detected: " + drift); - setPlaybackState(PlaybackState.buffering); + transition(PlaybackState.buffering); } else if (state == PlaybackState.buffering) { - setPlaybackState(PlaybackState.playing); + transition(PlaybackState.playing); } } - @Override - public void onCompletion(final MediaPlayer mp) { - setPlaybackState(PlaybackState.stopped); - } - @Override public void onMethodCall(final MethodCall call, final Result result) { final List args = (List)call.arguments; @@ -105,11 +122,14 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL result.success(null); break; case "stop": - stop(); - result.success(null); + stop(result); break; case "setVolume": - setVolume((Double)args.get(0)); + setVolume((float)((double)((Double)args.get(0)))); + result.success(null); + break; + case "setSpeed": + setSpeed((float)((double)((Double)args.get(0)))); result.success(null); break; case "seek": @@ -125,20 +145,17 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL } } catch (IllegalStateException e) { e.printStackTrace(); - result.error("Illegal state", null, null); + result.error("Illegal state: " + e.getMessage(), null, null); } catch (Exception e) { e.printStackTrace(); - result.error("Error", null, null); + result.error("Error: " + e, null, null); } } private void broadcastPlayerState() { final ArrayList event = new ArrayList(); - // state event.add(state.ordinal()); - // updatePosition event.add(updatePosition = getCurrentPosition()); - // updateTime event.add(updateTime = System.currentTimeMillis()); eventSink.success(event); } @@ -146,101 +163,243 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL private int getCurrentPosition() { if (state == PlaybackState.none || state == PlaybackState.connecting) { return 0; - } else if (seekPos != null) { - return seekPos; + } else if (seekRequests.size() > 0) { + return seekRequests.peekFirst().pos; } else { - return player.getCurrentPosition(); + return (int)(extractor.getSampleTime() / 1000); } } - private void setPlaybackState(final PlaybackState state) { - final PlaybackState oldState = this.state; - this.state = state; - if (oldState != PlaybackState.playing && state == PlaybackState.playing) { + private void transition(final PlaybackState newState) { + transition(state, newState); + } + + private void transition(final PlaybackState oldState, final PlaybackState newState) { + state = newState; + if (oldState != PlaybackState.playing && newState == PlaybackState.playing) { startObservingPosition(); } broadcastPlayerState(); } - public void setUrl(final String url, final Result result) throws IOException { - setPlaybackState(PlaybackState.connecting); - player.reset(); - player.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + private void bgTransition(final PlaybackState newState) { + bgTransition(state, newState); + } + + private void bgTransition(final PlaybackState oldState, final PlaybackState newState) { + // Redundant assignment which ensures the state is set + // immediately in the background thread. + state = newState; + handler.post(new Runnable() { @Override - public void onPrepared(final MediaPlayer mp) { - setPlaybackState(PlaybackState.stopped); - result.success(mp.getDuration()); + public void run() { + transition(oldState, newState); } }); - player.setDataSource(url); - player.prepareAsync(); + } + + public void setUrl(final String url, final Result result) throws IOException { + if (state != PlaybackState.none && state != PlaybackState.stopped) { + throw new IllegalStateException("Can call setUrl only from none and stopped states"); + } + transition(PlaybackState.connecting); + this.url = url; + if (extractor != null) { + extractor.release(); + } + new Thread(() -> { + try { + blockingInitExtractorAndCodec(); + + sonic = new Sonic(sampleRate, channelCount); + sonic.setVolume(volume); + sonic.setSpeed(speed); + + bgTransition(PlaybackState.stopped); + handler.post(() -> result.success(duration)); + } catch (Exception e) { + e.printStackTrace(); + handler.post(() -> result.error("Error: " + e, null, null)); + } + }).start(); } public void play(final Integer untilPosition) { - // TODO: dynamically adjust the lag. - final int lag = 6; - final int start = getCurrentPosition(); if (untilPosition != null && untilPosition <= start) { - return; + throw new IllegalArgumentException("untilPosition must be >= 0"); } - player.start(); - setPlaybackState(PlaybackState.playing); - if (endDetector != null) { - handler.removeCallbacks(endDetector); + this.untilPosition = untilPosition; + switch (state) { + case stopped: + ensureStopped(); + transition(PlaybackState.playing); + playThread = new PlayThread(); + playThread.start(); + break; + case paused: + synchronized (monitor) { + transition(PlaybackState.playing); + monitor.notifyAll(); + } + break; + default: + throw new IllegalStateException("Can call play only from stopped and paused states (" + state + ")"); } - if (untilPosition != null) { - final int duration = Math.max(0, untilPosition - start - lag); - handler.postDelayed(new Runnable() { - @Override - public void run() { - final int position = getCurrentPosition(); - if (position > untilPosition - 20) { - pause(); - } else { - final int duration = Math.max(0, untilPosition - position - lag); - handler.postDelayed(this, duration); - } + } + + private void ensureStopped() { + synchronized (monitor) { + try { + while (playThread != null) { + monitor.wait(); } - }, duration); + } catch (Exception e) {} } } public void pause() { - player.pause(); - setPlaybackState(PlaybackState.paused); - } - - public void stop() { - player.pause(); - player.seekTo(0); - setPlaybackState(PlaybackState.stopped); - } - - public void setVolume(final double volume) { - player.setVolume((float)volume, (float)volume); - } - - public void seek(final int position, final Result result) { - stateBeforeSeek = state; - seekPos = position; - handler.removeCallbacks(positionObserver); - setPlaybackState(PlaybackState.buffering); - player.setOnSeekCompleteListener(new MediaPlayer.OnSeekCompleteListener() { - @Override - public void onSeekComplete(final MediaPlayer mp) { - seekPos = null; - setPlaybackState(stateBeforeSeek); - stateBeforeSeek = null; - result.success(null); - player.setOnSeekCompleteListener(null); + switch (state) { + case playing: + case buffering: + synchronized (monitor) { + transition(PlaybackState.paused); + audioTrack.pause(); + monitor.notifyAll(); } - }); - player.seekTo(position); + break; + default: + throw new IllegalStateException("Can call pause only from playing and buffering states"); + } + } + + public void stop(final Result result) { + switch (state) { + case stopped: + break; + // TODO: Allow stopping from buffered state. + case playing: + case paused: + synchronized (monitor) { + // It takes some time for the PlayThread to actually wind down + // so other methods that transition from the stopped state should + // wait for playThread == null with ensureStopped(). + PlaybackState oldState = state; + transition(PlaybackState.stopped); + if (oldState == PlaybackState.paused) { + monitor.notifyAll(); + } else { + audioTrack.pause(); + } + new Thread(() -> { + ensureStopped(); + handler.post(() -> result.success(null)); + }).start(); + } + break; + default: + throw new IllegalStateException("Can call stop only from playing, paused and buffering states"); + } + } + + public void setVolume(final float volume) { + this.volume = volume; + if (sonic != null) { + sonic.setVolume(volume); + } + } + + public void setSpeed(final float speed) { + // NOTE: existing audio data in the pipeline will continue + // to play out at the speed it was already processed at. So + // for a brief moment, checkForDiscontinuity() may erroneously + // detect some buffering. + // TODO: Sort this out. The cheap workaround would be to disable + // checks for discontinuity during this brief moment. + this.speed = speed; + if (sonic != null) { + sonic.setSpeed(speed); + } + broadcastPlayerState(); + } + + // TODO: Test whether this times out the MediaCodec on Ogg files. + // See: https://stackoverflow.com/questions/22109050/mediacodec-dequeueoutputbuffer-times-out-when-seeking-with-ogg-audio-files + public void seek(final int position, final Result result) { + synchronized (monitor) { + if (state == PlaybackState.none || state == PlaybackState.connecting) { + throw new IllegalStateException("Cannot call seek in none or connecting states"); + } + if (state == PlaybackState.stopped) { + ensureStopped(); + } + start = position; + if (seekRequests.size() == 0) { + stateBeforeSeek = state; + } + seekRequests.addLast(new SeekRequest(position, result)); + handler.removeCallbacks(positionObserver); + transition(PlaybackState.buffering); + if (stateBeforeSeek == PlaybackState.stopped) { + new Thread(() -> { + processSeekRequests(); + }).start(); + } else { + monitor.notifyAll(); + } + } } public void dispose() { - player.release(); - setPlaybackState(PlaybackState.none); + if (state != PlaybackState.stopped && state != PlaybackState.none) { + throw new IllegalStateException("Can call dispose only from stopped and none states"); + } + if (extractor != null) { + ensureStopped(); + transition(PlaybackState.none); + extractor.release(); + extractor = null; + codec.stop(); + codec.release(); + codec = null; + } + } + + private void blockingInitExtractorAndCodec() throws IOException { + extractor = new MediaExtractor(); + extractor.setDataSource(url); + format = selectAudioTrack(extractor); + channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE); + long durationMs = format.getLong(MediaFormat.KEY_DURATION); + duration = (int)(durationMs / 1000); + start = 0; + codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)); + codec.configure(format, null, null, 0); + codec.start(); + } + + private MediaFormat selectAudioTrack(MediaExtractor extractor) throws IOException { + int trackCount = extractor.getTrackCount(); + for (int i = 0; i < trackCount; i++) { + MediaFormat format = extractor.getTrackFormat(i); + if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) { + extractor.selectTrack(i); + return format; + } + } + throw new RuntimeException("No audio track found"); + } + + private void processSeekRequests() { + while (seekRequests.size() > 0) { + SeekRequest seekRequest = seekRequests.removeFirst(); + extractor.seekTo(seekRequest.pos*1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC); + if (seekRequests.size() == 0) { + bgTransition(stateBeforeSeek); + stateBeforeSeek = null; + } + handler.post(() -> seekRequest.result.success(null)); + } } private void startObservingPosition() { @@ -248,6 +407,217 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL handler.post(positionObserver); } + private class PlayThread extends Thread { + private static final int TIMEOUT = 1000; + private static final int FRAME_SIZE = 1024*2; + private static final int BEHIND_LIMIT = 500; // ms + private byte[] silence; + private boolean finishedDecoding = false; + + @Override + public void run() { + + int encoding = AudioFormat.ENCODING_PCM_16BIT; + int channelFormat = channelCount==1?AudioFormat.CHANNEL_OUT_MONO:AudioFormat.CHANNEL_OUT_STEREO; + int minSize = AudioTrack.getMinBufferSize(sampleRate, channelFormat, encoding); + int audioTrackBufferSize = minSize * 4; + audioTrack = new AudioTrack( + AudioManager.STREAM_MUSIC, + sampleRate, + channelFormat, + encoding, + audioTrackBufferSize, + AudioTrack.MODE_STREAM); + + silence = new byte[audioTrackBufferSize]; + + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + boolean firstSample = true; + int decoderIdleCount = 0; + boolean finishedReading = false; + int progress = 0; + // The extractor position seems to jump around at the beginning. + // This is a hack to address that. + long behindStartTime = 0; + byte[] sonicOut = new byte[audioTrackBufferSize]; + try { + audioTrack.play(); + while (!finishedDecoding) { + + if (checkForRequest()) continue; + + // put data into input buffer + + if (!finishedReading) { + int inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT); + if (inputBufferIndex >= 0) { + ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferIndex); + long presentationTime = extractor.getSampleTime(); + int presentationTimeMs = (int)(presentationTime / 1000); + if (presentationTimeMs < start) { + if (behindStartTime == 0) behindStartTime = System.currentTimeMillis(); + if (System.currentTimeMillis() - behindStartTime > BEHIND_LIMIT) { + System.out.println("Too early, re-seeking"); + extractor.seekTo(start*1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC); + behindStartTime = 0; + } + } else { + behindStartTime = 0; + } + int sampleSize = extractor.readSampleData(inputBuffer, 0); + if (firstSample && sampleSize == 2 && format.getString(MediaFormat.KEY_MIME).equals("audio/mp4a-latm")) { + // Skip initial frames. + extractor.advance(); + } else if (sampleSize >= 0) { + codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0); + extractor.advance(); + } else { + codec.queueInputBuffer(inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + finishedReading = true; + } + firstSample = false; + } + } + + if (checkForRequest()) continue; + + // read data from output buffer + + int outputBufferIndex = codec.dequeueOutputBuffer(info, TIMEOUT); + decoderIdleCount++; + if (outputBufferIndex >= 0) { + int currentPosition = (int)(info.presentationTimeUs/1000); + ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex); + if (info.size > 0) { + decoderIdleCount = 0; + + final byte[] chunk = new byte[info.size]; + buf.get(chunk); + buf.clear(); + + // put decoded data into sonic + + if (chunk.length > 0) { + sonic.writeBytesToStream(chunk, chunk.length); + } else { + sonic.flushStream(); + } + + // output sonic'd data to audioTrack + + int numWritten; + do { + numWritten = sonic.readBytesFromStream(sonicOut, sonicOut.length); + if (numWritten > 0) { + audioTrack.write(sonicOut, 0, numWritten); + } + } while (numWritten > 0); + } + + // Detect end of playback + + codec.releaseOutputBuffer(outputBufferIndex, false); + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + if (untilPosition != null) { + extractor.release(); + codec.flush(); + codec.stop(); + codec.release(); + blockingInitExtractorAndCodec(); + finishedReading = false; + finishedDecoding = false; + decoderIdleCount = 0; + audioTrack.pause(); + bgTransition(PlaybackState.paused); + } else { + audioTrack.pause(); + finishedDecoding = true; + } + } else if (untilPosition != null && currentPosition >= untilPosition) { + // NOTE: When streaming audio over bluetooth, it clips off + // the last 200-300ms of the clip, even though it has been + // written to the AudioTrack. So, we need an option to pad the + // audio with an extra 200-300ms of silence. + // Could be a good idea to do the same at the start + // since some bluetooth headphones fade in and miss the + // first little bit. + + Arrays.fill(sonicOut, (byte)0); + audioTrack.write(sonicOut, 0, sonicOut.length); + bgTransition(PlaybackState.paused); + audioTrack.pause(); + } + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + // Don't expect this to happen in audio files, but could be wrong. + // TODO: Investigate. + //MediaFormat newFormat = codec.getOutputFormat(); + } + if (decoderIdleCount >= 100) { + // Data has stopped coming through the pipeline despite not receiving a + // BUFFER_FLAG_END_OF_STREAM signal, so stop. + System.out.println("decoderIdleCount >= 100. finishedDecoding = true"); + finishedDecoding = true; + audioTrack.pause(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + codec.flush(); + audioTrack.flush(); + audioTrack.release(); + audioTrack = null; + synchronized (monitor) { + start = 0; + untilPosition = null; + bgTransition(PlaybackState.stopped); + extractor.seekTo(0L, MediaExtractor.SEEK_TO_CLOSEST_SYNC); + playThread = null; + monitor.notifyAll(); + } + } + } + + // Return true to "continue" to the audio loop + private boolean checkForRequest() { + try { + synchronized (monitor) { + if (state == PlaybackState.paused) { + while (state == PlaybackState.paused) { + monitor.wait(); + } + // Unpaused + // Reset updateTime for higher accuracy. + bgTransition(state); + if (state == PlaybackState.playing) { + audioTrack.play(); + } else if (state == PlaybackState.buffering) { + // TODO: What if we are in the second checkForRequest call and + // we ask to continue the loop, we may forget about dequeued + // input buffers. Need to handle this correctly. + return true; + } + } else if (state == PlaybackState.buffering && seekRequests.size() > 0) { + // Seek requested + codec.flush(); + audioTrack.flush(); + processSeekRequests(); + if (state != PlaybackState.stopped) { + // The == stopped case is handled below. + return true; + } + } + if (state == PlaybackState.stopped) { + finishedDecoding = true; + return true; + } + } + } + catch (Exception e) {} + return false; + } + } + enum PlaybackState { none, stopped, @@ -256,4 +626,14 @@ public class AudioPlayer implements MethodCallHandler, MediaPlayer.OnCompletionL buffering, connecting } + + class SeekRequest { + public final int pos; + public final Result result; + + public SeekRequest(int pos, Result result) { + this.pos = pos; + this.result = result; + } + } } diff --git a/android/src/main/java/com/ryanheise/just_audio/JustAudioPlugin.java b/android/src/main/java/com/ryanheise/just_audio/JustAudioPlugin.java index 072c3b1..3888d4e 100644 --- a/android/src/main/java/com/ryanheise/just_audio/JustAudioPlugin.java +++ b/android/src/main/java/com/ryanheise/just_audio/JustAudioPlugin.java @@ -5,6 +5,7 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugin.common.PluginRegistry.Registrar; +import java.util.List; /** JustAudioPlugin */ public class JustAudioPlugin implements MethodCallHandler { @@ -24,7 +25,8 @@ public class JustAudioPlugin implements MethodCallHandler { public void onMethodCall(MethodCall call, Result result) { switch (call.method) { case "init": - String id = (String)call.arguments; + final List args = (List)call.arguments; + String id = (String)args.get(0); new AudioPlayer(registrar, id); result.success(null); break; diff --git a/android/src/main/java/sonic/Sonic.java b/android/src/main/java/sonic/Sonic.java new file mode 100644 index 0000000..47e5e46 --- /dev/null +++ b/android/src/main/java/sonic/Sonic.java @@ -0,0 +1,1081 @@ +/* Sonic library + Copyright 2010, 2011 + Bill Cox + This file is part of the Sonic Library. + + This file is licensed under the Apache 2.0 license. +*/ + +package sonic; + +public class Sonic { + + private static final int SONIC_MIN_PITCH = 65; + private static final int SONIC_MAX_PITCH = 400; + // This is used to down-sample some inputs to improve speed + private static final int SONIC_AMDF_FREQ = 4000; + // The number of points to use in the sinc FIR filter for resampling. + private static final int SINC_FILTER_POINTS = 12; + private static final int SINC_TABLE_SIZE = 601; + + // Lookup table for windowed sinc function of SINC_FILTER_POINTS points. + private static final short sincTable[] = { + 0, 0, 0, 0, 0, 0, 0, -1, -1, -2, -2, -3, -4, -6, -7, -9, -10, -12, -14, + -17, -19, -21, -24, -26, -29, -32, -34, -37, -40, -42, -44, -47, -48, -50, + -51, -52, -53, -53, -53, -52, -50, -48, -46, -43, -39, -34, -29, -22, -16, + -8, 0, 9, 19, 29, 41, 53, 65, 79, 92, 107, 121, 137, 152, 168, 184, 200, + 215, 231, 247, 262, 276, 291, 304, 317, 328, 339, 348, 357, 363, 369, 372, + 374, 375, 373, 369, 363, 355, 345, 332, 318, 300, 281, 259, 234, 208, 178, + 147, 113, 77, 39, 0, -41, -85, -130, -177, -225, -274, -324, -375, -426, + -478, -530, -581, -632, -682, -731, -779, -825, -870, -912, -951, -989, + -1023, -1053, -1080, -1104, -1123, -1138, -1149, -1154, -1155, -1151, + -1141, -1125, -1105, -1078, -1046, -1007, -963, -913, -857, -796, -728, + -655, -576, -492, -403, -309, -210, -107, 0, 111, 225, 342, 462, 584, 708, + 833, 958, 1084, 1209, 1333, 1455, 1575, 1693, 1807, 1916, 2022, 2122, 2216, + 2304, 2384, 2457, 2522, 2579, 2625, 2663, 2689, 2706, 2711, 2705, 2687, + 2657, 2614, 2559, 2491, 2411, 2317, 2211, 2092, 1960, 1815, 1658, 1489, + 1308, 1115, 912, 698, 474, 241, 0, -249, -506, -769, -1037, -1310, -1586, + -1864, -2144, -2424, -2703, -2980, -3254, -3523, -3787, -4043, -4291, + -4529, -4757, -4972, -5174, -5360, -5531, -5685, -5819, -5935, -6029, + -6101, -6150, -6175, -6175, -6149, -6096, -6015, -5905, -5767, -5599, + -5401, -5172, -4912, -4621, -4298, -3944, -3558, -3141, -2693, -2214, + -1705, -1166, -597, 0, 625, 1277, 1955, 2658, 3386, 4135, 4906, 5697, 6506, + 7332, 8173, 9027, 9893, 10769, 11654, 12544, 13439, 14335, 15232, 16128, + 17019, 17904, 18782, 19649, 20504, 21345, 22170, 22977, 23763, 24527, + 25268, 25982, 26669, 27327, 27953, 28547, 29107, 29632, 30119, 30569, + 30979, 31349, 31678, 31964, 32208, 32408, 32565, 32677, 32744, 32767, + 32744, 32677, 32565, 32408, 32208, 31964, 31678, 31349, 30979, 30569, + 30119, 29632, 29107, 28547, 27953, 27327, 26669, 25982, 25268, 24527, + 23763, 22977, 22170, 21345, 20504, 19649, 18782, 17904, 17019, 16128, + 15232, 14335, 13439, 12544, 11654, 10769, 9893, 9027, 8173, 7332, 6506, + 5697, 4906, 4135, 3386, 2658, 1955, 1277, 625, 0, -597, -1166, -1705, + -2214, -2693, -3141, -3558, -3944, -4298, -4621, -4912, -5172, -5401, + -5599, -5767, -5905, -6015, -6096, -6149, -6175, -6175, -6150, -6101, + -6029, -5935, -5819, -5685, -5531, -5360, -5174, -4972, -4757, -4529, + -4291, -4043, -3787, -3523, -3254, -2980, -2703, -2424, -2144, -1864, + -1586, -1310, -1037, -769, -506, -249, 0, 241, 474, 698, 912, 1115, 1308, + 1489, 1658, 1815, 1960, 2092, 2211, 2317, 2411, 2491, 2559, 2614, 2657, + 2687, 2705, 2711, 2706, 2689, 2663, 2625, 2579, 2522, 2457, 2384, 2304, + 2216, 2122, 2022, 1916, 1807, 1693, 1575, 1455, 1333, 1209, 1084, 958, 833, + 708, 584, 462, 342, 225, 111, 0, -107, -210, -309, -403, -492, -576, -655, + -728, -796, -857, -913, -963, -1007, -1046, -1078, -1105, -1125, -1141, + -1151, -1155, -1154, -1149, -1138, -1123, -1104, -1080, -1053, -1023, -989, + -951, -912, -870, -825, -779, -731, -682, -632, -581, -530, -478, -426, + -375, -324, -274, -225, -177, -130, -85, -41, 0, 39, 77, 113, 147, 178, + 208, 234, 259, 281, 300, 318, 332, 345, 355, 363, 369, 373, 375, 374, 372, + 369, 363, 357, 348, 339, 328, 317, 304, 291, 276, 262, 247, 231, 215, 200, + 184, 168, 152, 137, 121, 107, 92, 79, 65, 53, 41, 29, 19, 9, 0, -8, -16, + -22, -29, -34, -39, -43, -46, -48, -50, -52, -53, -53, -53, -52, -51, -50, + -48, -47, -44, -42, -40, -37, -34, -32, -29, -26, -24, -21, -19, -17, -14, + -12, -10, -9, -7, -6, -4, -3, -2, -2, -1, -1, 0, 0, 0, 0, 0, 0, 0 + }; + + private short inputBuffer[]; + private short outputBuffer[]; + private short pitchBuffer[]; + private short downSampleBuffer[]; + private float speed; + private float volume; + private float pitch; + private float rate; + private int oldRatePosition; + private int newRatePosition; + private boolean useChordPitch; + private int quality; + private int numChannels; + private int inputBufferSize; + private int pitchBufferSize; + private int outputBufferSize; + private int numInputSamples; + private int numOutputSamples; + private int numPitchSamples; + private int minPeriod; + private int maxPeriod; + private int maxRequired; + private int remainingInputToCopy; + private int sampleRate; + private int prevPeriod; + private int prevMinDiff; + private int minDiff; + private int maxDiff; + + // Resize the array. + private short[] resize( + short[] oldArray, + int newLength) + { + newLength *= numChannels; + short[] newArray = new short[newLength]; + int length = oldArray.length <= newLength? oldArray.length : newLength; + + System.arraycopy(oldArray, 0, newArray, 0, length); + return newArray; + } + + // Move samples from one array to another. May move samples down within an array, but not up. + private void move( + short dest[], + int destPos, + short source[], + int sourcePos, + int numSamples) + { + System.arraycopy(source, sourcePos*numChannels, dest, destPos*numChannels, numSamples*numChannels); + } + + // Scale the samples by the factor. + private void scaleSamples( + short samples[], + int position, + int numSamples, + float volume) + { + int fixedPointVolume = (int)(volume*4096.0f); + int start = position*numChannels; + int stop = start + numSamples*numChannels; + + for(int xSample = start; xSample < stop; xSample++) { + int value = (samples[xSample]*fixedPointVolume) >> 12; + if(value > 32767) { + value = 32767; + } else if(value < -32767) { + value = -32767; + } + samples[xSample] = (short)value; + } + } + + // Get the speed of the stream. + public float getSpeed() + { + return speed; + } + + // Set the speed of the stream. + public void setSpeed( + float speed) + { + this.speed = speed; + } + + // Get the pitch of the stream. + public float getPitch() + { + return pitch; + } + + // Set the pitch of the stream. + public void setPitch( + float pitch) + { + this.pitch = pitch; + } + + // Get the rate of the stream. + public float getRate() + { + return rate; + } + + // Set the playback rate of the stream. This scales pitch and speed at the same time. + public void setRate( + float rate) + { + this.rate = rate; + this.oldRatePosition = 0; + this.newRatePosition = 0; + } + + // Get the vocal chord pitch setting. + public boolean getChordPitch() + { + return useChordPitch; + } + + // Set the vocal chord mode for pitch computation. Default is off. + public void setChordPitch( + boolean useChordPitch) + { + this.useChordPitch = useChordPitch; + } + + // Get the quality setting. + public int getQuality() + { + return quality; + } + + // Set the "quality". Default 0 is virtually as good as 1, but very much faster. + public void setQuality( + int quality) + { + this.quality = quality; + } + + // Get the scaling factor of the stream. + public float getVolume() + { + return volume; + } + + // Set the scaling factor of the stream. + public void setVolume( + float volume) + { + this.volume = volume; + } + + // Allocate stream buffers. + private void allocateStreamBuffers( + int sampleRate, + int numChannels) + { + minPeriod = sampleRate/SONIC_MAX_PITCH; + maxPeriod = sampleRate/SONIC_MIN_PITCH; + maxRequired = 2*maxPeriod; + inputBufferSize = maxRequired; + inputBuffer = new short[maxRequired*numChannels]; + outputBufferSize = maxRequired; + outputBuffer = new short[maxRequired*numChannels]; + pitchBufferSize = maxRequired; + pitchBuffer = new short[maxRequired*numChannels]; + downSampleBuffer = new short[maxRequired]; + this.sampleRate = sampleRate; + this.numChannels = numChannels; + oldRatePosition = 0; + newRatePosition = 0; + prevPeriod = 0; + } + + // Create a sonic stream. + public Sonic( + int sampleRate, + int numChannels) + { + allocateStreamBuffers(sampleRate, numChannels); + speed = 1.0f; + pitch = 1.0f; + volume = 1.0f; + rate = 1.0f; + oldRatePosition = 0; + newRatePosition = 0; + useChordPitch = false; + quality = 0; + } + + // Get the sample rate of the stream. + public int getSampleRate() + { + return sampleRate; + } + + // Set the sample rate of the stream. This will cause samples buffered in the stream to be lost. + public void setSampleRate( + int sampleRate) + { + allocateStreamBuffers(sampleRate, numChannels); + } + + // Get the number of channels. + public int getNumChannels() + { + return numChannels; + } + + // Set the num channels of the stream. This will cause samples buffered in the stream to be lost. + public void setNumChannels( + int numChannels) + { + allocateStreamBuffers(sampleRate, numChannels); + } + + // Enlarge the output buffer if needed. + private void enlargeOutputBufferIfNeeded( + int numSamples) + { + if(numOutputSamples + numSamples > outputBufferSize) { + outputBufferSize += (outputBufferSize >> 1) + numSamples; + outputBuffer = resize(outputBuffer, outputBufferSize); + } + } + + // Enlarge the input buffer if needed. + private void enlargeInputBufferIfNeeded( + int numSamples) + { + if(numInputSamples + numSamples > inputBufferSize) { + inputBufferSize += (inputBufferSize >> 1) + numSamples; + inputBuffer = resize(inputBuffer, inputBufferSize); + } + } + + // Add the input samples to the input buffer. + private void addFloatSamplesToInputBuffer( + float samples[], + int numSamples) + { + if(numSamples == 0) { + return; + } + enlargeInputBufferIfNeeded(numSamples); + int xBuffer = numInputSamples*numChannels; + for(int xSample = 0; xSample < numSamples*numChannels; xSample++) { + inputBuffer[xBuffer++] = (short)(samples[xSample]*32767.0f); + } + numInputSamples += numSamples; + } + + // Add the input samples to the input buffer. + private void addShortSamplesToInputBuffer( + short samples[], + int numSamples) + { + if(numSamples == 0) { + return; + } + enlargeInputBufferIfNeeded(numSamples); + move(inputBuffer, numInputSamples, samples, 0, numSamples); + numInputSamples += numSamples; + } + + // Add the input samples to the input buffer. + private void addUnsignedByteSamplesToInputBuffer( + byte samples[], + int numSamples) + { + short sample; + + enlargeInputBufferIfNeeded(numSamples); + int xBuffer = numInputSamples*numChannels; + for(int xSample = 0; xSample < numSamples*numChannels; xSample++) { + sample = (short)((samples[xSample] & 0xff) - 128); // Convert from unsigned to signed + inputBuffer[xBuffer++] = (short) (sample << 8); + } + numInputSamples += numSamples; + } + + // Add the input samples to the input buffer. They must be 16-bit little-endian encoded in a byte array. + private void addBytesToInputBuffer( + byte inBuffer[], + int numBytes) + { + int numSamples = numBytes/(2*numChannels); + short sample; + + enlargeInputBufferIfNeeded(numSamples); + int xBuffer = numInputSamples*numChannels; + for(int xByte = 0; xByte + 1 < numBytes; xByte += 2) { + sample = (short)((inBuffer[xByte] & 0xff) | (inBuffer[xByte + 1] << 8)); + inputBuffer[xBuffer++] = sample; + } + numInputSamples += numSamples; + } + + // Remove input samples that we have already processed. + private void removeInputSamples( + int position) + { + int remainingSamples = numInputSamples - position; + + move(inputBuffer, 0, inputBuffer, position, remainingSamples); + numInputSamples = remainingSamples; + } + + // Just copy from the array to the output buffer + private void copyToOutput( + short samples[], + int position, + int numSamples) + { + enlargeOutputBufferIfNeeded(numSamples); + move(outputBuffer, numOutputSamples, samples, position, numSamples); + numOutputSamples += numSamples; + } + + // Just copy from the input buffer to the output buffer. Return num samples copied. + private int copyInputToOutput( + int position) + { + int numSamples = remainingInputToCopy; + + if(numSamples > maxRequired) { + numSamples = maxRequired; + } + copyToOutput(inputBuffer, position, numSamples); + remainingInputToCopy -= numSamples; + return numSamples; + } + + // Read data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readFloatFromStream( + float samples[], + int maxSamples) + { + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if(numSamples == 0) { + return 0; + } + if(numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + for(int xSample = 0; xSample < numSamples*numChannels; xSample++) { + samples[xSample] = (outputBuffer[xSample])/32767.0f; + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return numSamples; + } + + // Read short data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readShortFromStream( + short samples[], + int maxSamples) + { + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if(numSamples == 0) { + return 0; + } + if(numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + move(samples, 0, outputBuffer, 0, numSamples); + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return numSamples; + } + + // Read unsigned byte data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readUnsignedByteFromStream( + byte samples[], + int maxSamples) + { + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if(numSamples == 0) { + return 0; + } + if(numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + for(int xSample = 0; xSample < numSamples*numChannels; xSample++) { + samples[xSample] = (byte)((outputBuffer[xSample] >> 8) + 128); + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return numSamples; + } + + // Read unsigned byte data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readBytesFromStream( + byte outBuffer[], + int maxBytes) + { + int maxSamples = maxBytes/(2*numChannels); + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if(numSamples == 0 || maxSamples == 0) { + return 0; + } + if(numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + for(int xSample = 0; xSample < numSamples*numChannels; xSample++) { + short sample = outputBuffer[xSample]; + outBuffer[xSample << 1] = (byte)(sample & 0xff); + outBuffer[(xSample << 1) + 1] = (byte)(sample >> 8); + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return 2*numSamples*numChannels; + } + + // Force the sonic stream to generate output using whatever data it currently + // has. No extra delay will be added to the output, but flushing in the middle of + // words could introduce distortion. + public void flushStream() + { + int remainingSamples = numInputSamples; + float s = speed/pitch; + float r = rate*pitch; + int expectedOutputSamples = numOutputSamples + (int)((remainingSamples/s + numPitchSamples)/r + 0.5f); + + // Add enough silence to flush both input and pitch buffers. + enlargeInputBufferIfNeeded(remainingSamples + 2*maxRequired); + for(int xSample = 0; xSample < 2*maxRequired*numChannels; xSample++) { + inputBuffer[remainingSamples*numChannels + xSample] = 0; + } + numInputSamples += 2*maxRequired; + writeShortToStream(null, 0); + // Throw away any extra samples we generated due to the silence we added. + if(numOutputSamples > expectedOutputSamples) { + numOutputSamples = expectedOutputSamples; + } + // Empty input and pitch buffers. + numInputSamples = 0; + remainingInputToCopy = 0; + numPitchSamples = 0; + } + + // Return the number of samples in the output buffer + public int samplesAvailable() + { + return numOutputSamples; + } + + // If skip is greater than one, average skip samples together and write them to + // the down-sample buffer. If numChannels is greater than one, mix the channels + // together as we down sample. + private void downSampleInput( + short samples[], + int position, + int skip) + { + int numSamples = maxRequired/skip; + int samplesPerValue = numChannels*skip; + int value; + + position *= numChannels; + for(int i = 0; i < numSamples; i++) { + value = 0; + for(int j = 0; j < samplesPerValue; j++) { + value += samples[position + i*samplesPerValue + j]; + } + value /= samplesPerValue; + downSampleBuffer[i] = (short)value; + } + } + + // Find the best frequency match in the range, and given a sample skip multiple. + // For now, just find the pitch of the first channel. + private int findPitchPeriodInRange( + short samples[], + int position, + int minPeriod, + int maxPeriod) + { + int bestPeriod = 0, worstPeriod = 255; + int minDiff = 1, maxDiff = 0; + + position *= numChannels; + for(int period = minPeriod; period <= maxPeriod; period++) { + int diff = 0; + for(int i = 0; i < period; i++) { + short sVal = samples[position + i]; + short pVal = samples[position + period + i]; + diff += sVal >= pVal? sVal - pVal : pVal - sVal; + } + /* Note that the highest number of samples we add into diff will be less + than 256, since we skip samples. Thus, diff is a 24 bit number, and + we can safely multiply by numSamples without overflow */ + if(diff*bestPeriod < minDiff*period) { + minDiff = diff; + bestPeriod = period; + } + if(diff*worstPeriod > maxDiff*period) { + maxDiff = diff; + worstPeriod = period; + } + } + this.minDiff = minDiff/bestPeriod; + this.maxDiff = maxDiff/worstPeriod; + + return bestPeriod; + } + + // At abrupt ends of voiced words, we can have pitch periods that are better + // approximated by the previous pitch period estimate. Try to detect this case. + private boolean prevPeriodBetter( + int minDiff, + int maxDiff, + boolean preferNewPeriod) + { + if(minDiff == 0 || prevPeriod == 0) { + return false; + } + if(preferNewPeriod) { + if(maxDiff > minDiff*3) { + // Got a reasonable match this period + return false; + } + if(minDiff*2 <= prevMinDiff*3) { + // Mismatch is not that much greater this period + return false; + } + } else { + if(minDiff <= prevMinDiff) { + return false; + } + } + return true; + } + + // Find the pitch period. This is a critical step, and we may have to try + // multiple ways to get a good answer. This version uses AMDF. To improve + // speed, we down sample by an integer factor get in the 11KHz range, and then + // do it again with a narrower frequency range without down sampling + private int findPitchPeriod( + short samples[], + int position, + boolean preferNewPeriod) + { + int period, retPeriod; + int skip = 1; + + if(sampleRate > SONIC_AMDF_FREQ && quality == 0) { + skip = sampleRate/SONIC_AMDF_FREQ; + } + if(numChannels == 1 && skip == 1) { + period = findPitchPeriodInRange(samples, position, minPeriod, maxPeriod); + } else { + downSampleInput(samples, position, skip); + period = findPitchPeriodInRange(downSampleBuffer, 0, minPeriod/skip, + maxPeriod/skip); + if(skip != 1) { + period *= skip; + int minP = period - (skip << 2); + int maxP = period + (skip << 2); + if(minP < minPeriod) { + minP = minPeriod; + } + if(maxP > maxPeriod) { + maxP = maxPeriod; + } + if(numChannels == 1) { + period = findPitchPeriodInRange(samples, position, minP, maxP); + } else { + downSampleInput(samples, position, 1); + period = findPitchPeriodInRange(downSampleBuffer, 0, minP, maxP); + } + } + } + if(prevPeriodBetter(minDiff, maxDiff, preferNewPeriod)) { + retPeriod = prevPeriod; + } else { + retPeriod = period; + } + prevMinDiff = minDiff; + prevPeriod = period; + return retPeriod; + } + + // Overlap two sound segments, ramp the volume of one down, while ramping the + // other one from zero up, and add them, storing the result at the output. + private void overlapAdd( + int numSamples, + int numChannels, + short out[], + int outPos, + short rampDown[], + int rampDownPos, + short rampUp[], + int rampUpPos) + { + for(int i = 0; i < numChannels; i++) { + int o = outPos*numChannels + i; + int u = rampUpPos*numChannels + i; + int d = rampDownPos*numChannels + i; + for(int t = 0; t < numSamples; t++) { + out[o] = (short)((rampDown[d]*(numSamples - t) + rampUp[u]*t)/numSamples); + o += numChannels; + d += numChannels; + u += numChannels; + } + } + } + + // Overlap two sound segments, ramp the volume of one down, while ramping the + // other one from zero up, and add them, storing the result at the output. + private void overlapAddWithSeparation( + int numSamples, + int numChannels, + int separation, + short out[], + int outPos, + short rampDown[], + int rampDownPos, + short rampUp[], + int rampUpPos) + { + for(int i = 0; i < numChannels; i++) { + int o = outPos*numChannels + i; + int u = rampUpPos*numChannels + i; + int d = rampDownPos*numChannels + i; + for(int t = 0; t < numSamples + separation; t++) { + if(t < separation) { + out[o] = (short)(rampDown[d]*(numSamples - t)/numSamples); + d += numChannels; + } else if(t < numSamples) { + out[o] = (short)((rampDown[d]*(numSamples - t) + rampUp[u]*(t - separation))/numSamples); + d += numChannels; + u += numChannels; + } else { + out[o] = (short)(rampUp[u]*(t - separation)/numSamples); + u += numChannels; + } + o += numChannels; + } + } + } + + // Just move the new samples in the output buffer to the pitch buffer + private void moveNewSamplesToPitchBuffer( + int originalNumOutputSamples) + { + int numSamples = numOutputSamples - originalNumOutputSamples; + + if(numPitchSamples + numSamples > pitchBufferSize) { + pitchBufferSize += (pitchBufferSize >> 1) + numSamples; + pitchBuffer = resize(pitchBuffer, pitchBufferSize); + } + move(pitchBuffer, numPitchSamples, outputBuffer, originalNumOutputSamples, numSamples); + numOutputSamples = originalNumOutputSamples; + numPitchSamples += numSamples; + } + + // Remove processed samples from the pitch buffer. + private void removePitchSamples( + int numSamples) + { + if(numSamples == 0) { + return; + } + move(pitchBuffer, 0, pitchBuffer, numSamples, numPitchSamples - numSamples); + numPitchSamples -= numSamples; + } + + // Change the pitch. The latency this introduces could be reduced by looking at + // past samples to determine pitch, rather than future. + private void adjustPitch( + int originalNumOutputSamples) + { + int period, newPeriod, separation; + int position = 0; + + if(numOutputSamples == originalNumOutputSamples) { + return; + } + moveNewSamplesToPitchBuffer(originalNumOutputSamples); + while(numPitchSamples - position >= maxRequired) { + period = findPitchPeriod(pitchBuffer, position, false); + newPeriod = (int)(period/pitch); + enlargeOutputBufferIfNeeded(newPeriod); + if(pitch >= 1.0f) { + overlapAdd(newPeriod, numChannels, outputBuffer, numOutputSamples, pitchBuffer, + position, pitchBuffer, position + period - newPeriod); + } else { + separation = newPeriod - period; + overlapAddWithSeparation(period, numChannels, separation, outputBuffer, numOutputSamples, + pitchBuffer, position, pitchBuffer, position); + } + numOutputSamples += newPeriod; + position += period; + } + removePitchSamples(position); + } + + // Aproximate the sinc function times a Hann window from the sinc table. + private int findSincCoefficient(int i, int ratio, int width) { + int lobePoints = (SINC_TABLE_SIZE-1)/SINC_FILTER_POINTS; + int left = i*lobePoints + (ratio*lobePoints)/width; + int right = left + 1; + int position = i*lobePoints*width + ratio*lobePoints - left*width; + int leftVal = sincTable[left]; + int rightVal = sincTable[right]; + + return ((leftVal*(width - position) + rightVal*position) << 1)/width; + } + + // Return 1 if value >= 0, else -1. This represents the sign of value. + private int getSign(int value) { + return value >= 0? 1 : -1; + } + + // Interpolate the new output sample. + private short interpolate( + short in[], + int inPos, // Index to first sample which already includes channel offset. + int oldSampleRate, + int newSampleRate) + { + // Compute N-point sinc FIR-filter here. Clip rather than overflow. + int i; + int total = 0; + int position = newRatePosition*oldSampleRate; + int leftPosition = oldRatePosition*newSampleRate; + int rightPosition = (oldRatePosition + 1)*newSampleRate; + int ratio = rightPosition - position - 1; + int width = rightPosition - leftPosition; + int weight, value; + int oldSign; + int overflowCount = 0; + + for (i = 0; i < SINC_FILTER_POINTS; i++) { + weight = findSincCoefficient(i, ratio, width); + /* printf("%u %f\n", i, weight); */ + value = in[inPos + i*numChannels]*weight; + oldSign = getSign(total); + total += value; + if (oldSign != getSign(total) && getSign(value) == oldSign) { + /* We must have overflowed. This can happen with a sinc filter. */ + overflowCount += oldSign; + } + } + /* It is better to clip than to wrap if there was a overflow. */ + if (overflowCount > 0) { + return Short.MAX_VALUE; + } else if (overflowCount < 0) { + return Short.MIN_VALUE; + } + return (short)(total >> 16); + } + + // Change the rate. + private void adjustRate( + float rate, + int originalNumOutputSamples) + { + int newSampleRate = (int)(sampleRate/rate); + int oldSampleRate = sampleRate; + int position; + + // Set these values to help with the integer math + while(newSampleRate > (1 << 14) || oldSampleRate > (1 << 14)) { + newSampleRate >>= 1; + oldSampleRate >>= 1; + } + if(numOutputSamples == originalNumOutputSamples) { + return; + } + moveNewSamplesToPitchBuffer(originalNumOutputSamples); + // Leave at least one pitch sample in the buffer + for(position = 0; position < numPitchSamples - 1; position++) { + while((oldRatePosition + 1)*newSampleRate > newRatePosition*oldSampleRate) { + enlargeOutputBufferIfNeeded(1); + for(int i = 0; i < numChannels; i++) { + outputBuffer[numOutputSamples*numChannels + i] = interpolate(pitchBuffer, + position*numChannels + i, oldSampleRate, newSampleRate); + } + newRatePosition++; + numOutputSamples++; + } + oldRatePosition++; + if(oldRatePosition == oldSampleRate) { + oldRatePosition = 0; + if(newRatePosition != newSampleRate) { + System.out.printf("Assertion failed: newRatePosition != newSampleRate\n"); + assert false; + } + newRatePosition = 0; + } + } + removePitchSamples(position); + } + + + // Skip over a pitch period, and copy period/speed samples to the output + private int skipPitchPeriod( + short samples[], + int position, + float speed, + int period) + { + int newSamples; + + if(speed >= 2.0f) { + newSamples = (int)(period/(speed - 1.0f)); + } else { + newSamples = period; + remainingInputToCopy = (int)(period*(2.0f - speed)/(speed - 1.0f)); + } + enlargeOutputBufferIfNeeded(newSamples); + overlapAdd(newSamples, numChannels, outputBuffer, numOutputSamples, samples, position, + samples, position + period); + numOutputSamples += newSamples; + return newSamples; + } + + // Insert a pitch period, and determine how much input to copy directly. + private int insertPitchPeriod( + short samples[], + int position, + float speed, + int period) + { + int newSamples; + + if(speed < 0.5f) { + newSamples = (int)(period*speed/(1.0f - speed)); + } else { + newSamples = period; + remainingInputToCopy = (int)(period*(2.0f*speed - 1.0f)/(1.0f - speed)); + } + enlargeOutputBufferIfNeeded(period + newSamples); + move(outputBuffer, numOutputSamples, samples, position, period); + overlapAdd(newSamples, numChannels, outputBuffer, numOutputSamples + period, samples, + position + period, samples, position); + numOutputSamples += period + newSamples; + return newSamples; + } + + // Resample as many pitch periods as we have buffered on the input. Return 0 if + // we fail to resize an input or output buffer. Also scale the output by the volume. + private void changeSpeed( + float speed) + { + int numSamples = numInputSamples; + int position = 0, period, newSamples; + + if(numInputSamples < maxRequired) { + return; + } + do { + if(remainingInputToCopy > 0) { + newSamples = copyInputToOutput(position); + position += newSamples; + } else { + period = findPitchPeriod(inputBuffer, position, true); + if(speed > 1.0) { + newSamples = skipPitchPeriod(inputBuffer, position, speed, period); + position += period + newSamples; + } else { + newSamples = insertPitchPeriod(inputBuffer, position, speed, period); + position += newSamples; + } + } + } while(position + maxRequired <= numSamples); + removeInputSamples(position); + } + + // Resample as many pitch periods as we have buffered on the input. Scale the output by the volume. + private void processStreamInput() + { + int originalNumOutputSamples = numOutputSamples; + float s = speed/pitch; + float r = rate; + + if(!useChordPitch) { + r *= pitch; + } + if(s > 1.00001 || s < 0.99999) { + changeSpeed(s); + } else { + copyToOutput(inputBuffer, 0, numInputSamples); + numInputSamples = 0; + } + if(useChordPitch) { + if(pitch != 1.0f) { + adjustPitch(originalNumOutputSamples); + } + } else if(r != 1.0f) { + adjustRate(r, originalNumOutputSamples); + } + if(volume != 1.0f) { + // Adjust output volume. + scaleSamples(outputBuffer, originalNumOutputSamples, numOutputSamples - originalNumOutputSamples, + volume); + } + } + + // Write floating point data to the input buffer and process it. + public void writeFloatToStream( + float samples[], + int numSamples) + { + addFloatSamplesToInputBuffer(samples, numSamples); + processStreamInput(); + } + + // Write the data to the input stream, and process it. + public void writeShortToStream( + short samples[], + int numSamples) + { + addShortSamplesToInputBuffer(samples, numSamples); + processStreamInput(); + } + + // Simple wrapper around sonicWriteFloatToStream that does the unsigned byte to short + // conversion for you. + public void writeUnsignedByteToStream( + byte samples[], + int numSamples) + { + addUnsignedByteSamplesToInputBuffer(samples, numSamples); + processStreamInput(); + } + + // Simple wrapper around sonicWriteBytesToStream that does the byte to 16-bit LE conversion. + public void writeBytesToStream( + byte inBuffer[], + int numBytes) + { + addBytesToInputBuffer(inBuffer, numBytes); + processStreamInput(); + } + + // This is a non-stream oriented interface to just change the speed of a sound sample + public static int changeFloatSpeed( + float samples[], + int numSamples, + float speed, + float pitch, + float rate, + float volume, + boolean useChordPitch, + int sampleRate, + int numChannels) + { + Sonic stream = new Sonic(sampleRate, numChannels); + + stream.setSpeed(speed); + stream.setPitch(pitch); + stream.setRate(rate); + stream.setVolume(volume); + stream.setChordPitch(useChordPitch); + stream.writeFloatToStream(samples, numSamples); + stream.flushStream(); + numSamples = stream.samplesAvailable(); + stream.readFloatFromStream(samples, numSamples); + return numSamples; + } + + /* This is a non-stream oriented interface to just change the speed of a sound sample */ + public int sonicChangeShortSpeed( + short samples[], + int numSamples, + float speed, + float pitch, + float rate, + float volume, + boolean useChordPitch, + int sampleRate, + int numChannels) + { + Sonic stream = new Sonic(sampleRate, numChannels); + + stream.setSpeed(speed); + stream.setPitch(pitch); + stream.setRate(rate); + stream.setVolume(volume); + stream.setChordPitch(useChordPitch); + stream.writeShortToStream(samples, numSamples); + stream.flushStream(); + numSamples = stream.samplesAvailable(); + stream.readShortFromStream(samples, numSamples); + return numSamples; + } +} diff --git a/example/.gitignore b/example/.gitignore index 2ddde2a..4b91960 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -24,6 +24,7 @@ **/doc/api/ .dart_tool/ .flutter-plugins +.flutter-plugins-dependencies .packages .pub-cache/ .pub/ diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index da95139..af63e3e 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -34,11 +34,11 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.ryanheise.just_audio_example" - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 28 versionCode flutterVersionCode.toInteger() versionName flutterVersionName - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -56,6 +56,6 @@ flutter { dependencies { testImplementation 'junit:junit:4.12' - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + androidTestImplementation 'androidx.test:runner:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' } diff --git a/example/android/build.gradle b/example/android/build.gradle index bb8a303..e0d7ae2 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' + classpath 'com.android.tools.build:gradle:3.5.0' } } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 2bd6f4f..38c8d45 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,2 +1,4 @@ org.gradle.jvmargs=-Xmx1536M - +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 2819f02..63ab3ae 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/example/lib/main.dart b/example/lib/main.dart index 692ac0f..158218b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,10 +1,7 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; -import 'dart:async'; -import 'package:flutter/services.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; void main() => runApp(MyApp()); @@ -14,11 +11,14 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - final AudioPlayer _player = AudioPlayer(); + final _volumeSubject = BehaviorSubject.seeded(1.0); + final _speedSubject = BehaviorSubject.seeded(1.0); + AudioPlayer _player; @override void initState() { super.initState(); + _player = AudioPlayer(); _player.setUrl( "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3"); } @@ -82,6 +82,7 @@ class _MyAppState extends State { ); }, ), + Text("Track position"), StreamBuilder( stream: _player.durationStream, builder: (context, snapshot) { @@ -101,6 +102,34 @@ class _MyAppState extends State { ); }, ), + Text("Volume"), + StreamBuilder( + stream: _volumeSubject.stream, + builder: (context, snapshot) => Slider( + divisions: 20, + min: 0.0, + max: 2.0, + value: snapshot.data ?? 1.0, + onChanged: (value) { + _volumeSubject.add(value); + _player.setVolume(value); + }, + ), + ), + Text("Speed"), + StreamBuilder( + stream: _speedSubject.stream, + builder: (context, snapshot) => Slider( + divisions: 10, + min: 0.5, + max: 1.5, + value: snapshot.data ?? 1.0, + onChanged: (value) { + _speedSubject.add(value); + _player.setSpeed(value); + }, + ), + ), ], ), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index b90ee21..7f21c2b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -7,7 +7,7 @@ packages: name: archive url: "https://pub.dartlang.org" source: hosted - version: "2.0.10" + version: "2.0.11" args: dependency: transitive description: @@ -21,7 +21,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.4.0" boolean_selector: dependency: transitive description: @@ -87,21 +87,21 @@ packages: path: ".." relative: true source: path - version: "0.0.1" + version: "0.0.2" matcher: dependency: transitive description: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.5" + version: "0.12.6" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.1.7" + version: "1.1.8" path: dependency: transitive description: @@ -145,12 +145,12 @@ packages: source: hosted version: "2.0.5" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.22.6" + version: "0.23.1" sky_engine: dependency: transitive description: flutter @@ -197,7 +197,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.5" + version: "0.2.11" typed_data: dependency: transitive description: @@ -220,5 +220,5 @@ packages: source: hosted version: "3.5.0" sdks: - dart: ">=2.4.0 <3.0.0" + dart: ">=2.6.0 <3.0.0" flutter: ">=1.9.1+hotfix.5 <2.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index fec03ab..a7ffc56 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,9 +6,8 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^0.1.2 + rxdart: ^0.23.1 dev_dependencies: flutter_test: diff --git a/ios/Classes/AudioPlayer.m b/ios/Classes/AudioPlayer.m index a6e9148..1f39101 100644 --- a/ios/Classes/AudioPlayer.m +++ b/ios/Classes/AudioPlayer.m @@ -60,6 +60,9 @@ } else if ([@"setVolume" isEqualToString:call.method]) { [self setVolume:(float)[args[0] doubleValue]]; result(nil); + } else if ([@"setSpeed" isEqualToString:call.method]) { + [self setSpeed:(float)[args[0] doubleValue]]; + result(nil); } else if ([@"seek" isEqualToString:call.method]) { [self seek:[args[0] intValue] result:result]; result(nil); @@ -92,7 +95,7 @@ long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); int position = [self getCurrentPosition]; long long timeSinceLastUpdate = now - _updateTime; - long long expectedPosition = _updatePosition + timeSinceLastUpdate; + long long expectedPosition = _updatePosition + timeSinceLastUpdate; // TODO: * speed long long drift = position - expectedPosition; // Update if we've drifted or just started observing if (_updateTime == 0L) { @@ -257,6 +260,14 @@ [_player setVolume:volume]; } +- (void)setSpeed:(float)speed { + //if (speed == 1.0 + // || speed < 1.0 && _player.currentItem.canPlaySlowForward + // || speed > 1.0 && _player.currentItem.canPlayFastForward) { + // _player.rate = speed; + //} +} + - (void)seek:(int)position result:(FlutterResult)result { _stateBeforeSeek = _state; _seekPos = position; diff --git a/ios/Classes/JustAudioPlugin.m b/ios/Classes/JustAudioPlugin.m index f9e4a7d..ca4a235 100644 --- a/ios/Classes/JustAudioPlugin.m +++ b/ios/Classes/JustAudioPlugin.m @@ -3,32 +3,33 @@ #import "AudioPlayer.h" @implementation JustAudioPlugin { - NSObject* _registrar; + NSObject* _registrar; } + (void)registerWithRegistrar:(NSObject*)registrar { - FlutterMethodChannel* channel = [FlutterMethodChannel - methodChannelWithName:@"com.ryanheise.just_audio.methods" - binaryMessenger:[registrar messenger]]; - JustAudioPlugin* instance = [[JustAudioPlugin alloc] initWithRegistrar:registrar]; - [registrar addMethodCallDelegate:instance channel:channel]; + FlutterMethodChannel* channel = [FlutterMethodChannel + methodChannelWithName:@"com.ryanheise.just_audio.methods" + binaryMessenger:[registrar messenger]]; + JustAudioPlugin* instance = [[JustAudioPlugin alloc] initWithRegistrar:registrar]; + [registrar addMethodCallDelegate:instance channel:channel]; } - (instancetype)initWithRegistrar:(NSObject *)registrar { - self = [super init]; - NSAssert(self, @"super init cannot be nil"); - _registrar = registrar; - return self; + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + _registrar = registrar; + return self; } - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - if ([@"init" isEqualToString:call.method]) { - NSString* playerId = call.arguments; - AudioPlayer* player = [[AudioPlayer alloc] initWithRegistrar:_registrar playerId:playerId]; - result(nil); - } else { - result(FlutterMethodNotImplemented); - } + if ([@"init" isEqualToString:call.method]) { + NSArray* args = (NSArray*)call.arguments; + NSString* playerId = args[0]; + AudioPlayer* player = [[AudioPlayer alloc] initWithRegistrar:_registrar playerId:playerId]; + result(nil); + } else { + result(FlutterMethodNotImplemented); + } } @end diff --git a/lib/just_audio.dart b/lib/just_audio.dart index 936ac23..e0489ee 100644 --- a/lib/just_audio.dart +++ b/lib/just_audio.dart @@ -46,8 +46,8 @@ import 'package:rxdart/rxdart.dart'; class AudioPlayer { static final _mainChannel = MethodChannel('com.ryanheise.just_audio.methods'); - static Future _createChannel(int id) async { - await _mainChannel.invokeMethod('init', '$id'); + static Future _init(int id) async { + await _mainChannel.invokeMethod('init', ['$id']); return MethodChannel('com.ryanheise.just_audio.methods.$id'); } @@ -69,22 +69,31 @@ class AudioPlayer { final _playbackStateSubject = BehaviorSubject(); + double _volume = 1.0; + + double _speed = 1.0; + /// Creates an [AudioPlayer]. factory AudioPlayer() => AudioPlayer._internal(DateTime.now().microsecondsSinceEpoch); - AudioPlayer._internal(this._id) : _channel = _createChannel(_id) { + AudioPlayer._internal(this._id) : _channel = _init(_id) { _eventChannelStream = EventChannel('com.ryanheise.just_audio.events.$_id') .receiveBroadcastStream() .map((data) => _audioPlayerState = AudioPlayerState( state: AudioPlaybackState.values[data[0]], updatePosition: Duration(milliseconds: data[1]), updateTime: Duration(milliseconds: data[2]), + speed: _speed, )); _eventChannelStreamSubscription = _eventChannelStream.listen(_playerStateSubject.add); _playbackStateSubject .addStream(playerStateStream.map((state) => state.state).distinct()); + + playerStateStream.listen((state) { + print("state: $state"); + }); } /// The duration of any media set via [setUrl], [setFilePath] or [setAsset], @@ -107,11 +116,18 @@ class AudioPlayer { /// A stream periodically tracking the current position of this player. Stream getPositionStream( [final Duration period = const Duration(milliseconds: 200)]) => - Observable.combineLatest2( + Rx.combineLatest2( playerStateStream, - Observable.periodic(period), + // TODO: emit periodically only in playing state. + Stream.periodic(period), (state, _) => state.position); + /// The current volume of the player. + double get volume => _volume; + + /// The current speed of the player. + double get speed => _speed; + /// Loads audio media from a URL and returns the duration of that audio. Future setUrl(final String url) async { _durationFuture = @@ -170,10 +186,11 @@ class AudioPlayer { } /// Stops the currently playing media such that the next [play] invocation - /// will start from position 0. It is legal to invoke this method from any - /// state except for: + /// will start from position 0. It is legal to invoke this method only from + /// the following states: /// - /// * [AudioPlaybackState.none] + /// * [AudioPlaybackState.playing] + /// * [AudioPlaybackState.paused] /// * [AudioPlaybackState.stopped] Future stop() async { await _invokeMethod('stop'); @@ -181,9 +198,16 @@ class AudioPlayer { /// Sets the volume of this player, where 1.0 is normal volume. Future setVolume(final double volume) async { + _volume = volume; await _invokeMethod('setVolume', [volume]); } + /// Sets the playback speed of this player, where 1.0 is normal speed. + Future setSpeed(final double speed) async { + _speed = speed; + await _invokeMethod('setSpeed', [speed]); + } + /// Seeks to a particular position. It is legal to invoke this method /// from any state except for [AudioPlaybackState.none]. Future seek(final Duration position) async { @@ -218,18 +242,26 @@ class AudioPlayerState { /// The position at [updateTime]. final Duration updatePosition; + /// The playback speed. + final double speed; + AudioPlayerState({ @required this.state, @required this.updateTime, @required this.updatePosition, + @required this.speed, }); /// The current position of the player. Duration get position => state == AudioPlaybackState.playing ? updatePosition + (Duration(milliseconds: DateTime.now().millisecondsSinceEpoch) - - updateTime) + updateTime) * + speed : updatePosition; + + @override + String toString() => "{state=$state, updateTime=$updateTime, updatePosition=$updatePosition, speed=$speed}"; } /// Enumerates the different playback states of a player. diff --git a/pubspec.lock b/pubspec.lock index ac76c57..2aa7e7c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,13 +1,27 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.2" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.4.0" boolean_selector: dependency: transitive description: @@ -29,6 +43,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.14.11" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" flutter: dependency: "direct main" description: flutter @@ -39,20 +67,27 @@ packages: description: flutter source: sdk version: "0.0.0" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" matcher: dependency: transitive description: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.5" + version: "0.12.6" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.1.7" + version: "1.1.8" path: dependency: "direct main" description: @@ -74,6 +109,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0+1" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0" platform: dependency: transitive description: @@ -94,7 +136,7 @@ packages: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.22.6" + version: "0.23.1" sky_engine: dependency: transitive description: flutter @@ -141,7 +183,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.5" + version: "0.2.11" typed_data: dependency: transitive description: @@ -156,6 +198,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.5.0" sdks: - dart: ">=2.2.2 <3.0.0" + dart: ">=2.6.0 <3.0.0" flutter: ">=1.9.1+hotfix.5 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 32f3ce7..c370514 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ environment: sdk: ">=2.1.0 <3.0.0" dependencies: - rxdart: ^0.22.6 + rxdart: ^0.23.1 path: ^1.6.4 path_provider: ^1.4.5 flutter: