fix ios audio

This commit is contained in:
otsmr 2026-10-10 20:14:52 +02:00
parent adf4fe0ca5
commit f5c217c6c1
6 changed files with 162 additions and 41 deletions

View file

@ -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"
} }

View file

@ -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()
}
}
}

View file

@ -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}")

View file

@ -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,

View file

@ -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()

View file

@ -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
} }
} }