From 16fe3ba21a97fa8c2f1ae34480ad39816aeaf45b Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 3 Apr 2026 10:19:57 +0200 Subject: [PATCH 1/3] =?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/3] =?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 From 6373544cb7375bc1a91a2c3a88432fa9b70151c0 Mon Sep 17 00:00:00 2001 From: Ruben Date: Mon, 6 Jul 2026 09:17:40 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9E=20fix(android):=20guard=20enco?= =?UTF-8?q?der=20against=20released-codec=20races?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purge pending handler messages in stopEncoder() before releasing the codec, so quitSafely() cannot drain queued work against a released codec - Guard onInputBufferAvailable and feedEncoder with isEncoderStopped and catch IllegalStateException in feedEncoder, which also runs on the recording thread - Mark cross-thread fields (currentInputBufferIndex, isEncodingComplete, isEncoderStopped, totalBytesEncoded) as @Volatile for visibility - Reuse a single main-thread Handler in AudioRecorder instead of allocating one per audio chunk - Document the EOS dependency in signalToStop --- .../simform/audio_waveforms/AudioRecorder.kt | 5 +- .../audio_waveforms/encoders/CommonEncoder.kt | 53 +++++++++++++------ 2 files changed, 39 insertions(+), 19 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 23df1df5..d33c5d04 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt @@ -39,6 +39,7 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { private var wavEncoder: WavEncoder? = null private var successCallback: RequestPermissionsSuccessCallback? = null private var totalSamples = 0L + private val mainHandler = Handler(Looper.getMainLooper()) private val channelCount: Int get() = when (channelConfig) { AudioFormat.CHANNEL_IN_MONO -> 1 @@ -195,7 +196,7 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { val hashMap = HashMap() hashMap[Constants.resultFilePath] = recorderSettings?.path hashMap[Constants.resultDuration] = duration - Handler(Looper.getMainLooper()).post { + mainHandler.post { result.success(hashMap) } } @@ -205,7 +206,7 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { args[Constants.normalisedRms] = rms args[Constants.bytes] = chunk args[Constants.recordedDuration] = milliSeconds - Handler(Looper.getMainLooper()).post { + mainHandler.post { channel.invokeMethod(Constants.onAudioChunk, args) } } 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 37f36e56..3b198291 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 @@ -55,15 +55,18 @@ class CommonEncoder { private val inputQueue = LinkedList() /** Current available input buffer index (-1 if none available) */ + @Volatile private var currentInputBufferIndex = -1 /** Flag indicating if the muxer has been started */ private var isMuxerStarted = false /** Flag indicating encoding process should complete */ + @Volatile private var isEncodingComplete = false - + /** Flag indicating encoder has been stopped */ + @Volatile private var isEncoderStopped = false /** Track index for the audio track in the muxer */ @@ -73,6 +76,7 @@ class CommonEncoder { private var completionCallback: (() -> Unit)? = null /** Total bytes encoded so far, used for calculating presentation timestamps */ + @Volatile private var totalBytesEncoded = 0L /** Track the first output timestamp to normalize subsequent timestamps */ @@ -152,6 +156,7 @@ class CommonEncoder { mediaCodec.setCallback(object : MediaCodec.Callback() { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + if (isEncoderStopped) return if (isEncodingComplete && inputQueue.isEmpty()) { queueEosBuffer(codec, index) } else { @@ -280,6 +285,9 @@ class CommonEncoder { Log.e(Constants.LOG_TAG, "Error queuing EOS in signalToStop: ${e.message}") } } + // Otherwise EOS is queued by a later onInputBufferAvailable once + // the queue drains; if the codec never returns an input buffer, + // the completion callback (and the Dart stop() future) would hang. } } @@ -322,25 +330,31 @@ class CommonEncoder { */ private fun feedEncoder() { synchronized(inputQueue) { + if (isEncoderStopped) return if (inputQueue.isEmpty() || currentInputBufferIndex < 0) return val data = inputQueue.poll() ?: return - val inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return - inputBuffer.clear() - inputBuffer.put(data) - - // Calculate presentation time based on actual audio data encoded - // Formula: presentationTimeUs = (totalBytes * 1,000,000) / (sampleRate * channels * bytesPerSample) - // For 16-bit PCM mono: bytesPerSample = 2, channels = 1 - val bytesPerSample = 2L // 16-bit = 2 bytes - val channels = 1L // Mono - val presentationTimeUs = (totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample) - totalBytesEncoded += data.size - - mediaCodec.queueInputBuffer( - currentInputBufferIndex, 0, data.size, presentationTimeUs, 0 - ) - currentInputBufferIndex = -1 + try { + val inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return + inputBuffer.clear() + inputBuffer.put(data) + + // Calculate presentation time based on actual audio data encoded + // Formula: presentationTimeUs = (totalBytes * 1,000,000) / (sampleRate * channels * bytesPerSample) + // For 16-bit PCM mono: bytesPerSample = 2, channels = 1 + val bytesPerSample = 2L // 16-bit = 2 bytes + val channels = 1L // Mono + val presentationTimeUs = (totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample) + totalBytesEncoded += data.size + + mediaCodec.queueInputBuffer( + currentInputBufferIndex, 0, data.size, presentationTimeUs, 0 + ) + currentInputBufferIndex = -1 + } catch (e: IllegalStateException) { + // The codec may have been released by stopEncoder() on another thread. + Log.e(Constants.LOG_TAG, "Error feeding encoder: ${e.message}") + } } } @@ -424,6 +438,11 @@ class CommonEncoder { if (isEncoderStopped) return isEncoderStopped = true + // Purge queued messages so quitSafely() can't run them against a + // released codec. MediaCodec's own callback messages live on its + // internal handler, hence the isEncoderStopped guards elsewhere. + handler.removeCallbacksAndMessages(null) + try { mediaCodec.stop() mediaCodec.release()