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 {
|
||||
minSdkVersion 21
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidTestImplementation 'androidx.test:runner:1.3.0'
|
||||
androidTestImplementation 'junit:junit:4.13.2'
|
||||
implementation("androidx.multidex:multidex:2.0.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() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
||||
if (isEncodingComplete && inputQueue.isEmpty()) {
|
||||
// Use the last calculated presentation time for EOF, not system time
|
||||
val eofTimestamp = if (totalBytesEncoded > 0) {
|
||||
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()
|
||||
}
|
||||
if (isEncoderStopped) return
|
||||
currentInputBufferIndex = index
|
||||
feedEncoder()
|
||||
}
|
||||
|
||||
override fun onOutputBufferAvailable(
|
||||
|
|
@ -244,7 +231,7 @@ class CommonEncoder {
|
|||
isMuxerStarted = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}, handler)
|
||||
|
||||
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
mediaCodec.start()
|
||||
|
|
@ -259,11 +246,9 @@ class CommonEncoder {
|
|||
* @param buffer The raw audio data to encode
|
||||
*/
|
||||
fun queueInputBuffer(buffer: ByteArray) {
|
||||
synchronized(inputQueue) {
|
||||
handler.post {
|
||||
if (isEncodingComplete || isEncoderStopped) return@post
|
||||
inputQueue.add(buffer)
|
||||
}
|
||||
|
||||
if (currentInputBufferIndex >= 0) {
|
||||
feedEncoder()
|
||||
}
|
||||
}
|
||||
|
|
@ -275,7 +260,13 @@ class CommonEncoder {
|
|||
* is processed, the encoder will be sent an end-of-stream signal.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
fun setOnEncodingCompleted(callback: () -> Unit) {
|
||||
completionCallback = callback
|
||||
handler.post { completionCallback = callback }
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -301,7 +292,18 @@ class CommonEncoder {
|
|||
*/
|
||||
private fun feedEncoder() {
|
||||
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 inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return
|
||||
|
|
@ -410,7 +412,9 @@ class CommonEncoder {
|
|||
mediaMuxer?.release()
|
||||
outputStream.close()
|
||||
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()
|
||||
} catch (e: Exception) {
|
||||
Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}")
|
||||
|
|
|
|||
|
|
@ -180,10 +180,20 @@ final class TestPixelBufferSource: NSObject, FVPPixelBufferSource {
|
|||
}
|
||||
|
||||
#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 {
|
||||
var category: AVAudioSession.Category = .ambient
|
||||
var categoryOptions: AVAudioSession.CategoryOptions = []
|
||||
private(set) var setCategoryCalled = false
|
||||
private(set) var activeCalls: [Bool] = []
|
||||
|
||||
func setActive(_ active: Bool, with options: AVAudioSession.SetActiveOptions) throws {
|
||||
activeCalls.append(active)
|
||||
}
|
||||
|
||||
func setCategory(
|
||||
_ category: AVAudioSession.Category,
|
||||
|
|
|
|||
|
|
@ -613,6 +613,65 @@ private let hlsAudioTestURI =
|
|||
}
|
||||
|
||||
#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 {
|
||||
let stubFactory = StubFVPAVFactory()
|
||||
let audioSession = TestAudioSession()
|
||||
|
|
|
|||
|
|
@ -192,11 +192,6 @@ static NSDictionary<NSString *, NSValue *> *FVPGetPlayerItemObservations(void) {
|
|||
if (_onDisposed) {
|
||||
_onDisposed();
|
||||
}
|
||||
#if TARGET_OS_IOS
|
||||
if (_avFactory != nil) {
|
||||
[[_avFactory sharedAudioSession] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
|
||||
}
|
||||
#endif
|
||||
[self.eventListener videoPlayerWasDisposed];
|
||||
}
|
||||
|
||||
|
|
@ -224,11 +219,6 @@ static NSDictionary<NSString *, NSValue *> *FVPGetPlayerItemObservations(void) {
|
|||
AVPlayerItem *p = [notification object];
|
||||
[p seekToTime:kCMTimeZero completionHandler:nil];
|
||||
} else {
|
||||
#if TARGET_OS_IOS
|
||||
if (_avFactory != nil) {
|
||||
[[_avFactory sharedAudioSession] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
|
||||
}
|
||||
#endif
|
||||
[self.eventListener videoPlayerDidComplete];
|
||||
}
|
||||
}
|
||||
|
|
@ -348,6 +338,15 @@ NS_INLINE CGFloat radiansToDegrees(CGFloat radians) {
|
|||
return;
|
||||
}
|
||||
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
|
||||
// version) so last set playback speed must be set here if any instead.
|
||||
// https://github.com/flutter/flutter/issues/71264
|
||||
|
|
@ -359,11 +358,6 @@ NS_INLINE CGFloat radiansToDegrees(CGFloat radians) {
|
|||
}
|
||||
} else {
|
||||
[_player pause];
|
||||
#if TARGET_OS_IOS
|
||||
if (_avFactory != nil) {
|
||||
[[_avFactory sharedAudioSession] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue