From 16fe3ba21a97fa8c2f1ae34480ad39816aeaf45b Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 3 Apr 2026 10:19:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9E=20fix(android):=20ensure=20EOS?= =?UTF-8?q?,=20release=20and=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent encoder hang and ensure proper cleanup when stopping recordings. Add queueEosBuffer helper and proactively queue EOS in signalToStop if an input buffer is available, run MediaCodec callback on the handler, move completionCallback to finally, call release() after sending results or on error, and post result.success on the main looper for thread-safety. --- .../simform/audio_waveforms/AudioRecorder.kt | 10 ++-- .../audio_waveforms/encoders/CommonEncoder.kt | 47 ++++++++++++++----- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt b/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt index 24fd73bd..23df1df5 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt @@ -176,18 +176,18 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { wavEncoder?.stop(result) recordingThread?.join() sendRecordingResult(result) + release() } else { commonEncoder.setOnEncodingCompleted { sendRecordingResult(result) + release() } commonEncoder.signalToStop() } - } catch (e: Exception) { result.error(LOG_TAG, e.message, "An error occurred while stopping the recorder") - return + release() } - release() } private fun sendRecordingResult(result: Result) { @@ -195,7 +195,9 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { val hashMap = HashMap() hashMap[Constants.resultFilePath] = recorderSettings?.path hashMap[Constants.resultDuration] = duration - result.success(hashMap) + Handler(Looper.getMainLooper()).post { + result.success(hashMap) + } } private fun sendBytesToFlutter(chunk: ByteArray, rms: Double, milliSeconds: Long) { diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt b/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt index 182a7fa8..a9d967b6 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt @@ -153,17 +153,7 @@ 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 - ) + queueEosBuffer(codec, index) } else { currentInputBufferIndex = index feedEncoder() @@ -244,7 +234,7 @@ class CommonEncoder { isMuxerStarted = true } } - }) + }, handler) mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) mediaCodec.start() @@ -276,6 +266,20 @@ class CommonEncoder { */ fun signalToStop() { isEncodingComplete = true + + // If an input buffer is already available and the queue is empty, + // the onInputBufferAvailable callback won't fire again on its own. + // We must queue the EOS buffer now to avoid hanging forever. + synchronized(inputQueue) { + if (currentInputBufferIndex >= 0 && inputQueue.isEmpty()) { + try { + queueEosBuffer(mediaCodec, currentInputBufferIndex) + currentInputBufferIndex = -1 + } catch (e: Exception) { + Log.e(Constants.LOG_TAG, "Error queuing EOS in signalToStop: ${e.message}") + } + } + } } /** @@ -288,6 +292,22 @@ class CommonEncoder { } + /** + * Queues an end-of-stream buffer to signal that encoding is complete. + */ + private fun queueEosBuffer(codec: MediaCodec, bufferIndex: Int) { + val eofTimestamp = if (totalBytesEncoded > 0) { + val bytesPerSample = 2L + val channels = 1L + (totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample) + } else { + 0L + } + codec.queueInputBuffer( + bufferIndex, 0, 0, eofTimestamp, MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + } + /** * Feeds available audio data to the encoder * @@ -411,9 +431,10 @@ class CommonEncoder { outputStream.close() handlerThread.quitSafely() handlerThread.join() - completionCallback?.invoke() } catch (e: Exception) { Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}") + } finally { + completionCallback?.invoke() } // Reset state for next recording From 801cb348bfd8fb0119cee3dc724bbab938a52a7a Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 3 Apr 2026 10:45:52 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9E=20fix(encoder):=20post=20EOS?= =?UTF-8?q?=20on=20handler=20thread=20to=20avoid=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post the EOS check to the handler thread so EOS queuing runs on the same thread as onInputBufferAvailable, preventing race conditions when an input buffer is already available. Add an early return if the encoder is stopped. Move handlerThread.quitSafely() into the finally block and remove join() to avoid deadlocking when stopEncoder is invoked from callbacks; ensure completionCallback is invoked before quitting the thread. --- .../audio_waveforms/encoders/CommonEncoder.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt b/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt index a9d967b6..37f36e56 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt @@ -267,10 +267,11 @@ class CommonEncoder { fun signalToStop() { isEncodingComplete = true - // If an input buffer is already available and the queue is empty, - // the onInputBufferAvailable callback won't fire again on its own. - // We must queue the EOS buffer now to avoid hanging forever. - synchronized(inputQueue) { + // Post the EOS check to the handler thread so it runs on the same + // thread as onInputBufferAvailable, avoiding race conditions where + // both threads try to queue EOS with the same buffer index. + handler.post { + if (isEncoderStopped) return@post if (currentInputBufferIndex >= 0 && inputQueue.isEmpty()) { try { queueEosBuffer(mediaCodec, currentInputBufferIndex) @@ -429,12 +430,14 @@ class CommonEncoder { mediaMuxer?.stop() mediaMuxer?.release() outputStream.close() - handlerThread.quitSafely() - handlerThread.join() } catch (e: Exception) { Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}") } finally { completionCallback?.invoke() + // Quit the handler thread after invoking the callback. + // Don't call join() -- stopEncoder() is called from callbacks + // running on this same thread, so joining would deadlock. + handlerThread.quitSafely() } // Reset state for next recording