From f5c217c6c1bbdaa1a3f9be847d303c8712f4aec2 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 10 Oct 2026 20:14:52 +0200 Subject: [PATCH] fix ios audio --- audio_waveforms/android/build.gradle | 3 + .../encoders/CommonEncoderTest.kt | 51 ++++++++++++++++ .../audio_waveforms/encoders/CommonEncoder.kt | 56 ++++++++++-------- .../darwin/RunnerTests/TestClasses.swift | 10 ++++ .../darwin/RunnerTests/VideoPlayerTests.swift | 59 +++++++++++++++++++ .../FVPVideoPlayer.m | 24 +++----- 6 files changed, 162 insertions(+), 41 deletions(-) create mode 100644 audio_waveforms/android/src/androidTest/kotlin/com/simform/audio_waveforms/encoders/CommonEncoderTest.kt diff --git a/audio_waveforms/android/build.gradle b/audio_waveforms/android/build.gradle index a8b70a7..a268e84 100644 --- a/audio_waveforms/android/build.gradle +++ b/audio_waveforms/android/build.gradle @@ -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" } diff --git a/audio_waveforms/android/src/androidTest/kotlin/com/simform/audio_waveforms/encoders/CommonEncoderTest.kt b/audio_waveforms/android/src/androidTest/kotlin/com/simform/audio_waveforms/encoders/CommonEncoderTest.kt new file mode 100644 index 0000000..be79e0f --- /dev/null +++ b/audio_waveforms/android/src/androidTest/kotlin/com/simform/audio_waveforms/encoders/CommonEncoderTest.kt @@ -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() + } + } +} diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt index 182a7fa..98f0c06 100644 --- a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt @@ -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}") @@ -426,4 +430,4 @@ class CommonEncoder { firstOutputTimestamp = -1L lastOutputTimestamp = 0L } -} \ No newline at end of file +} diff --git a/video_player_avfoundation/darwin/RunnerTests/TestClasses.swift b/video_player_avfoundation/darwin/RunnerTests/TestClasses.swift index 24fcc1c..a87f4ba 100644 --- a/video_player_avfoundation/darwin/RunnerTests/TestClasses.swift +++ b/video_player_avfoundation/darwin/RunnerTests/TestClasses.swift @@ -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, diff --git a/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.swift b/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.swift index 72bbe1e..a3d9f76 100644 --- a/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.swift +++ b/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.swift @@ -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() diff --git a/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation_objc/FVPVideoPlayer.m b/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation_objc/FVPVideoPlayer.m index d2cd01d..a6b051e 100644 --- a/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation_objc/FVPVideoPlayer.m +++ b/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation_objc/FVPVideoPlayer.m @@ -192,11 +192,6 @@ static NSDictionary *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 *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 } }