fix ios audio
This commit is contained in:
parent
adf4fe0ca5
commit
f5c217c6c1
6 changed files with 162 additions and 41 deletions
|
|
@ -46,10 +46,13 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
androidTestImplementation 'androidx.test:runner:1.3.0'
|
||||||
|
androidTestImplementation 'junit:junit:4.13.2'
|
||||||
implementation("androidx.multidex:multidex:2.0.1")
|
implementation("androidx.multidex:multidex:2.0.1")
|
||||||
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.simform.audio_waveforms.encoders
|
||||||
|
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.simform.audio_waveforms.RecorderSettings
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class CommonEncoderTest {
|
||||||
|
private val result = object : MethodChannel.Result {
|
||||||
|
override fun success(value: Any?) = Unit
|
||||||
|
override fun error(code: String, message: String?, details: Any?) {
|
||||||
|
fail("$code: $message")
|
||||||
|
}
|
||||||
|
override fun notImplemented() = fail("Unexpected method")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stoppingAfterInputHasDrainedCompletesAndProducesPlayableAudio() {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val file = File(context.cacheDir, "encoder-stop-test.m4a")
|
||||||
|
val encoder = CommonEncoder()
|
||||||
|
val completed = CountDownLatch(1)
|
||||||
|
try {
|
||||||
|
encoder.initCodec(RecorderSettings(path = file.path), result)
|
||||||
|
// Half a second of mono PCM, followed by an idle microphone. This
|
||||||
|
// leaves an input buffer held by the encoder when Stop is pressed.
|
||||||
|
repeat(22) { encoder.queueInputBuffer(ByteArray(2048)) }
|
||||||
|
SystemClock.sleep(300)
|
||||||
|
encoder.setOnEncodingCompleted { completed.countDown() }
|
||||||
|
encoder.signalToStop()
|
||||||
|
assertTrue("Stopping must return the finished recording", completed.await(5, TimeUnit.SECONDS))
|
||||||
|
assertTrue(file.length() > 0)
|
||||||
|
val metadata = MediaMetadataRetriever()
|
||||||
|
try {
|
||||||
|
metadata.setDataSource(file.path)
|
||||||
|
val duration = metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||||
|
assertTrue("The recording must retain its audio", duration!!.toLong() >= 400)
|
||||||
|
} finally {
|
||||||
|
metadata.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -152,22 +152,9 @@ class CommonEncoder {
|
||||||
|
|
||||||
mediaCodec.setCallback(object : MediaCodec.Callback() {
|
mediaCodec.setCallback(object : MediaCodec.Callback() {
|
||||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
||||||
if (isEncodingComplete && inputQueue.isEmpty()) {
|
if (isEncoderStopped) return
|
||||||
// Use the last calculated presentation time for EOF, not system time
|
currentInputBufferIndex = index
|
||||||
val eofTimestamp = if (totalBytesEncoded > 0) {
|
feedEncoder()
|
||||||
val bytesPerSample = 2L
|
|
||||||
val channels = 1L
|
|
||||||
(totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample)
|
|
||||||
} else {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
codec.queueInputBuffer(
|
|
||||||
index, 0, 0, eofTimestamp, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
currentInputBufferIndex = index
|
|
||||||
feedEncoder()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOutputBufferAvailable(
|
override fun onOutputBufferAvailable(
|
||||||
|
|
@ -244,7 +231,7 @@ class CommonEncoder {
|
||||||
isMuxerStarted = true
|
isMuxerStarted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}, handler)
|
||||||
|
|
||||||
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
mediaCodec.start()
|
mediaCodec.start()
|
||||||
|
|
@ -259,11 +246,9 @@ class CommonEncoder {
|
||||||
* @param buffer The raw audio data to encode
|
* @param buffer The raw audio data to encode
|
||||||
*/
|
*/
|
||||||
fun queueInputBuffer(buffer: ByteArray) {
|
fun queueInputBuffer(buffer: ByteArray) {
|
||||||
synchronized(inputQueue) {
|
handler.post {
|
||||||
|
if (isEncodingComplete || isEncoderStopped) return@post
|
||||||
inputQueue.add(buffer)
|
inputQueue.add(buffer)
|
||||||
}
|
|
||||||
|
|
||||||
if (currentInputBufferIndex >= 0) {
|
|
||||||
feedEncoder()
|
feedEncoder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +260,13 @@ class CommonEncoder {
|
||||||
* is processed, the encoder will be sent an end-of-stream signal.
|
* is processed, the encoder will be sent an end-of-stream signal.
|
||||||
*/
|
*/
|
||||||
fun signalToStop() {
|
fun signalToStop() {
|
||||||
isEncodingComplete = true
|
handler.post {
|
||||||
|
if (isEncoderStopped) return@post
|
||||||
|
isEncodingComplete = true
|
||||||
|
// A codec buffer may already be waiting for PCM. No further
|
||||||
|
// input callback is guaranteed once recording stops.
|
||||||
|
feedEncoder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -284,7 +275,7 @@ class CommonEncoder {
|
||||||
* @param callback The function to call when encoding completes
|
* @param callback The function to call when encoding completes
|
||||||
*/
|
*/
|
||||||
fun setOnEncodingCompleted(callback: () -> Unit) {
|
fun setOnEncodingCompleted(callback: () -> Unit) {
|
||||||
completionCallback = callback
|
handler.post { completionCallback = callback }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -301,7 +292,18 @@ class CommonEncoder {
|
||||||
*/
|
*/
|
||||||
private fun feedEncoder() {
|
private fun feedEncoder() {
|
||||||
synchronized(inputQueue) {
|
synchronized(inputQueue) {
|
||||||
if (inputQueue.isEmpty() || currentInputBufferIndex < 0) return
|
if (isEncoderStopped || currentInputBufferIndex < 0) return
|
||||||
|
|
||||||
|
if (inputQueue.isEmpty()) {
|
||||||
|
if (isEncodingComplete) {
|
||||||
|
val timestamp = totalBytesEncoded * 1_000_000L / (recorderSettings.sampleRate * 2L)
|
||||||
|
mediaCodec.queueInputBuffer(
|
||||||
|
currentInputBufferIndex, 0, 0, timestamp, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
currentInputBufferIndex = -1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val data = inputQueue.poll() ?: return
|
val data = inputQueue.poll() ?: return
|
||||||
val inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return
|
val inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return
|
||||||
|
|
@ -410,7 +412,9 @@ class CommonEncoder {
|
||||||
mediaMuxer?.release()
|
mediaMuxer?.release()
|
||||||
outputStream.close()
|
outputStream.close()
|
||||||
handlerThread.quitSafely()
|
handlerThread.quitSafely()
|
||||||
handlerThread.join()
|
// Codec callbacks run on this thread; joining it here would block
|
||||||
|
// forever and prevent returning the completed recording to Flutter.
|
||||||
|
if (Thread.currentThread() !== handlerThread) handlerThread.join()
|
||||||
completionCallback?.invoke()
|
completionCallback?.invoke()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}")
|
Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}")
|
||||||
|
|
@ -426,4 +430,4 @@ class CommonEncoder {
|
||||||
firstOutputTimestamp = -1L
|
firstOutputTimestamp = -1L
|
||||||
lastOutputTimestamp = 0L
|
lastOutputTimestamp = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,10 +180,20 @@ final class TestPixelBufferSource: NSObject, FVPPixelBufferSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
/// Makes initialization deterministic without loading media from the network.
|
||||||
|
final class ReadyPlayerItem: AVPlayerItem {
|
||||||
|
override var status: AVPlayerItem.Status { .readyToPlay }
|
||||||
|
}
|
||||||
|
|
||||||
final class TestAudioSession: NSObject, FVPAVAudioSession {
|
final class TestAudioSession: NSObject, FVPAVAudioSession {
|
||||||
var category: AVAudioSession.Category = .ambient
|
var category: AVAudioSession.Category = .ambient
|
||||||
var categoryOptions: AVAudioSession.CategoryOptions = []
|
var categoryOptions: AVAudioSession.CategoryOptions = []
|
||||||
private(set) var setCategoryCalled = false
|
private(set) var setCategoryCalled = false
|
||||||
|
private(set) var activeCalls: [Bool] = []
|
||||||
|
|
||||||
|
func setActive(_ active: Bool, with options: AVAudioSession.SetActiveOptions) throws {
|
||||||
|
activeCalls.append(active)
|
||||||
|
}
|
||||||
|
|
||||||
func setCategory(
|
func setCategory(
|
||||||
_ category: AVAudioSession.Category,
|
_ category: AVAudioSession.Category,
|
||||||
|
|
|
||||||
|
|
@ -613,6 +613,65 @@ private let hlsAudioTestURI =
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
private func audioTestPlayer(session: TestAudioSession) -> FVPVideoPlayer {
|
||||||
|
let item = ReadyPlayerItem(asset: AVMutableComposition())
|
||||||
|
let factory = StubFVPAVFactory(player: AVPlayer(playerItem: item))
|
||||||
|
factory.audioSession = session
|
||||||
|
let player = FVPVideoPlayer(
|
||||||
|
playerItem: factory.playerItem, avFactory: factory, viewProvider: StubViewProvider())
|
||||||
|
player.eventListener = StubEventListener()
|
||||||
|
return player
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func videoAudioSessionIsActivatedOnPlayAndResume() throws {
|
||||||
|
let session = TestAudioSession()
|
||||||
|
let player = audioTestPlayer(session: session)
|
||||||
|
defer {
|
||||||
|
var error: FlutterError?
|
||||||
|
player.disposeWithError(&error)
|
||||||
|
}
|
||||||
|
// Initialization pauses the player before Dart requests playback.
|
||||||
|
#expect(session.activeCalls.isEmpty)
|
||||||
|
|
||||||
|
var error: FlutterError?
|
||||||
|
player.playWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
#expect(session.activeCalls == [true])
|
||||||
|
player.pauseWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
#expect(session.activeCalls == [true])
|
||||||
|
player.playWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
#expect(session.activeCalls == [true, true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anotherVideoCannotDeactivatePlayingVideoAudio() throws {
|
||||||
|
let session = TestAudioSession()
|
||||||
|
let playing = audioTestPlayer(session: session)
|
||||||
|
defer {
|
||||||
|
var error: FlutterError?
|
||||||
|
playing.disposeWithError(&error)
|
||||||
|
}
|
||||||
|
var error: FlutterError?
|
||||||
|
playing.playWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
let idle = audioTestPlayer(session: session)
|
||||||
|
idle.pauseWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
idle.disposeWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
#expect(session.activeCalls == [true])
|
||||||
|
|
||||||
|
let finishing = audioTestPlayer(session: session)
|
||||||
|
finishing.playWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .AVPlayerItemDidPlayToEndTime, object: finishing.player.currentItem)
|
||||||
|
finishing.disposeWithError(&error)
|
||||||
|
#expect(error == nil)
|
||||||
|
#expect(session.activeCalls == [true, true])
|
||||||
|
}
|
||||||
|
|
||||||
@Test func videoPlayerShouldNotOverwritePlayAndRecordNorDefaultToSpeaker() throws {
|
@Test func videoPlayerShouldNotOverwritePlayAndRecordNorDefaultToSpeaker() throws {
|
||||||
let stubFactory = StubFVPAVFactory()
|
let stubFactory = StubFVPAVFactory()
|
||||||
let audioSession = TestAudioSession()
|
let audioSession = TestAudioSession()
|
||||||
|
|
|
||||||
|
|
@ -192,11 +192,6 @@ static NSDictionary<NSString *, NSValue *> *FVPGetPlayerItemObservations(void) {
|
||||||
if (_onDisposed) {
|
if (_onDisposed) {
|
||||||
_onDisposed();
|
_onDisposed();
|
||||||
}
|
}
|
||||||
#if TARGET_OS_IOS
|
|
||||||
if (_avFactory != nil) {
|
|
||||||
[[_avFactory sharedAudioSession] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
[self.eventListener videoPlayerWasDisposed];
|
[self.eventListener videoPlayerWasDisposed];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,11 +219,6 @@ static NSDictionary<NSString *, NSValue *> *FVPGetPlayerItemObservations(void) {
|
||||||
AVPlayerItem *p = [notification object];
|
AVPlayerItem *p = [notification object];
|
||||||
[p seekToTime:kCMTimeZero completionHandler:nil];
|
[p seekToTime:kCMTimeZero completionHandler:nil];
|
||||||
} else {
|
} else {
|
||||||
#if TARGET_OS_IOS
|
|
||||||
if (_avFactory != nil) {
|
|
||||||
[[_avFactory sharedAudioSession] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
[self.eventListener videoPlayerDidComplete];
|
[self.eventListener videoPlayerDidComplete];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -348,6 +338,15 @@ NS_INLINE CGFloat radiansToDegrees(CGFloat radians) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isPlaying) {
|
if (_isPlaying) {
|
||||||
|
#if TARGET_OS_IOS
|
||||||
|
// Recording and interruptions can leave the shared session inactive. Activate it on every
|
||||||
|
// start/resume, before setting the rate. Individual players must not deactivate this session
|
||||||
|
// when paused, completed, or disposed: another player or plugin may still be using it.
|
||||||
|
NSError *audioSessionError = nil;
|
||||||
|
if (![[_avFactory sharedAudioSession] setActive:YES withOptions:0 error:&audioSessionError]) {
|
||||||
|
NSLog(@"Failed to activate video audio session: %@", audioSessionError);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// Calling play is the same as setting the rate to 1.0 (or to defaultRate depending on iOS
|
// Calling play is the same as setting the rate to 1.0 (or to defaultRate depending on iOS
|
||||||
// version) so last set playback speed must be set here if any instead.
|
// version) so last set playback speed must be set here if any instead.
|
||||||
// https://github.com/flutter/flutter/issues/71264
|
// https://github.com/flutter/flutter/issues/71264
|
||||||
|
|
@ -359,11 +358,6 @@ NS_INLINE CGFloat radiansToDegrees(CGFloat radians) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
[_player pause];
|
[_player pause];
|
||||||
#if TARGET_OS_IOS
|
|
||||||
if (_avFactory != nil) {
|
|
||||||
[[_avFactory sharedAudioSession] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue