From b17491c0eeb1f86e525e8c352af0ca317d0ce74d Mon Sep 17 00:00:00 2001 From: Awais Rana Date: Mon, 3 Aug 2026 15:52:42 +0500 Subject: [PATCH 1/3] fix: resolve Android self-join deadlock in CommonEncoder.stop() stopEncoder() ran on its own handlerThread and called handlerThread.join() on itself, deadlocking forever and preventing the completion callback from firing. RecorderController.stop() would then hang indefinitely on Android whenever the AAC/CommonEncoder path was used. Removes the self-join, invokes the completion callback before quitting the handler thread instead of after, posts MediaCodec callbacks and the method channel result onto explicit handlers for thread-safety, and adds @Volatile to fields read across threads. Cherry-picked from upstream PR SimformSolutionsPvtLtd/audio_waveforms#486. --- .../simform/audio_waveforms/AudioRecorder.kt | 13 ++- .../audio_waveforms/encoders/CommonEncoder.kt | 107 ++++++++++++------ 2 files changed, 83 insertions(+), 37 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..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 @@ -176,18 +177,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 +196,9 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { val hashMap = HashMap() hashMap[Constants.resultFilePath] = recorderSettings?.path hashMap[Constants.resultDuration] = duration - result.success(hashMap) + mainHandler.post { + result.success(hashMap) + } } private fun sendBytesToFlutter(chunk: ByteArray, rms: Double, milliSeconds: Long) { @@ -203,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 182a7fa8..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,18 +156,9 @@ class CommonEncoder { mediaCodec.setCallback(object : MediaCodec.Callback() { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + if (isEncoderStopped) return 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 +239,7 @@ class CommonEncoder { isMuxerStarted = true } } - }) + }, handler) mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) mediaCodec.start() @@ -276,6 +271,24 @@ class CommonEncoder { */ fun signalToStop() { isEncodingComplete = true + + // 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) + currentInputBufferIndex = -1 + } catch (e: Exception) { + 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. + } } /** @@ -288,6 +301,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 * @@ -301,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}") + } } } @@ -403,17 +438,25 @@ 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() mediaMuxer?.stop() mediaMuxer?.release() outputStream.close() - handlerThread.quitSafely() - handlerThread.join() - completionCallback?.invoke() } 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 e5abd337f39497fbb9edc1398f51127f508e9787 Mon Sep 17 00:00:00 2001 From: Awais Rana Date: Mon, 3 Aug 2026 16:20:44 +0500 Subject: [PATCH 2/3] fix: force-stop encoder if EOS is never queued naturally signalToStop() relies on a future onInputBufferAvailable callback to queue EOS when no input buffer is available at the moment stop() is called. But recording has already stopped feeding new audio by then, so that callback may never arrive, leaving stopEncoder() (and the completion callback / Dart-side stop() future) hanging indefinitely. Reproduced consistently on a second back-to-back recording. Add a 500ms grace-period fallback: if EOS hasn't been queued naturally by then, force stopEncoder() directly. It's idempotent and always invokes the completion callback, so the Dart Future can never hang. --- .../audio_waveforms/encoders/CommonEncoder.kt | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 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 3b198291..0d4e92c6 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 @@ -30,6 +30,14 @@ import java.util.LinkedList * and the encoded output file ready for playback. */ class CommonEncoder { + companion object { + /** + * How long to wait for EOS to be queued naturally by + * onInputBufferAvailable before forcing the encoder to stop. + */ + private const val EOS_FALLBACK_DELAY_MS = 500L + } + /** MediaCodec instance for encoding audio data */ private lateinit var mediaCodec: MediaCodec @@ -284,10 +292,26 @@ class CommonEncoder { } catch (e: Exception) { Log.e(Constants.LOG_TAG, "Error queuing EOS in signalToStop: ${e.message}") } + } else { + // No input buffer is available right now, or the queue hasn't + // drained. EOS would normally get queued by a later + // onInputBufferAvailable once that happens -- but recording has + // already stopped feeding new audio by the time stop() is + // called, so the codec may never request another input buffer, + // and that callback may never arrive. Force a stop after a + // short grace period so the Dart-side stop() future can never + // hang indefinitely; stopEncoder() is idempotent and always + // invokes the completion callback. + handler.postDelayed({ + if (!isEncoderStopped) { + Log.w( + Constants.LOG_TAG, + "EOS was not queued naturally within the grace period; forcing encoder stop" + ) + stopEncoder() + } + }, EOS_FALLBACK_DELAY_MS) } - // 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. } } From ec9bd9d676969ad5ed4569c1e29e53f66e3854c0 Mon Sep 17 00:00:00 2001 From: Awais Rana Date: Mon, 3 Aug 2026 16:49:45 +0500 Subject: [PATCH 3/3] feat: make the stop() EOS fallback grace period configurable Different devices/Codec2 implementations may need more or less time before the natural EOS path can be considered stalled. Hardcoding 500ms risked either forcing a stop too early on slower devices (truncating the recording's tail) or not being adjustable for apps that hit this more severely. Adds AndroidEncoderSettings.stopTimeoutMs (default 500, matching the previous hardcoded value), threaded through to RecorderSettings on both the Dart and native side, down to CommonEncoder.signalToStop()'s postDelayed fallback. --- .../com/simform/audio_waveforms/RecorderSettings.kt | 13 +++++++++++-- .../kotlin/com/simform/audio_waveforms/Utils.kt | 1 + .../audio_waveforms/encoders/CommonEncoder.kt | 10 +--------- lib/src/base/constants.dart | 1 + lib/src/models/android_encoder_settings.dart | 12 ++++++++++++ lib/src/models/recorder_settings.dart | 1 + 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt b/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt index b1d58dd9..5716603e 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt @@ -36,7 +36,15 @@ data class RecorderSettings( * Bit rate in bits per second * Defaults to 128kbps (good quality for most audio) */ - val bitRate: Int = 128000 + val bitRate: Int = 128000, + + /** + * Grace period, in milliseconds, to wait for the encoder to signal that + * it's finished stopping naturally before forcing it. Some devices can + * fail to deliver that signal, which would otherwise hang stopRecording() + * indefinitely. Defaults to 500ms. + */ + val stopTimeoutMs: Long = 500L ) { companion object { /** @@ -54,7 +62,8 @@ data class RecorderSettings( path = json[Constants.path] as String?, encoder = Encoder.fromString(json[Constants.encoder] as String?), sampleRate = (json[Constants.sampleRate] as Int?) ?: 44100, - bitRate = json[Constants.bitRate] as Int + bitRate = json[Constants.bitRate] as Int, + stopTimeoutMs = (json[Constants.androidStopTimeoutMs] as Int?)?.toLong() ?: 500L ) } } diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt b/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt index 59bbfcf2..bc02d0ce 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt @@ -39,6 +39,7 @@ object Constants { const val encoder = "encoder" const val sampleRate = "sampleRate" const val bitRate = "bitRate" + const val androidStopTimeoutMs = "androidStopTimeoutMs" const val fileNameFormat = "dd-MM-yy-hh-mm-ss" const val preparePlayer = "preparePlayer" 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 0d4e92c6..39667158 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 @@ -30,14 +30,6 @@ import java.util.LinkedList * and the encoded output file ready for playback. */ class CommonEncoder { - companion object { - /** - * How long to wait for EOS to be queued naturally by - * onInputBufferAvailable before forcing the encoder to stop. - */ - private const val EOS_FALLBACK_DELAY_MS = 500L - } - /** MediaCodec instance for encoding audio data */ private lateinit var mediaCodec: MediaCodec @@ -310,7 +302,7 @@ class CommonEncoder { ) stopEncoder() } - }, EOS_FALLBACK_DELAY_MS) + }, recorderSettings.stopTimeoutMs) } } } diff --git a/lib/src/base/constants.dart b/lib/src/base/constants.dart index 9bdd7924..ca83c426 100644 --- a/lib/src/base/constants.dart +++ b/lib/src/base/constants.dart @@ -58,4 +58,5 @@ class Constants { static const String normalisedRms = 'normalisedRms'; static const String bytes = 'bytes'; static const String recordedDuration = 'recordedDuration'; + static const String androidStopTimeoutMs = 'androidStopTimeoutMs'; } diff --git a/lib/src/models/android_encoder_settings.dart b/lib/src/models/android_encoder_settings.dart index d95c1622..1ffdac97 100644 --- a/lib/src/models/android_encoder_settings.dart +++ b/lib/src/models/android_encoder_settings.dart @@ -6,11 +6,23 @@ class AndroidEncoderSettings { /// /// [androidEncoder] - Defines the encoder type for Android (default: AAC). /// [androidOutputFormat] - Specifies the output format for Android recordings (default: MPEG4). + /// [stopTimeoutMs] - On some devices, the encoder can fail to naturally + /// signal that it's finished stopping, which would otherwise hang + /// stopRecording() forever. If stopping doesn't complete naturally within + /// this many milliseconds, it's forced instead. Default is 500ms; raise + /// this if you see stopRecording() forcing a stop on your target devices + /// sooner than expected (which can slightly truncate the very end of a + /// recording). const AndroidEncoderSettings({ this.androidEncoder = AndroidEncoder.aacLc, + this.stopTimeoutMs = 500, }); /// Encoder type for Android recordings. /// Default is aacLc. final AndroidEncoder androidEncoder; + + /// Grace period, in milliseconds, to wait for the encoder to stop + /// naturally before forcing it. See the constructor doc for details. + final int stopTimeoutMs; } diff --git a/lib/src/models/recorder_settings.dart b/lib/src/models/recorder_settings.dart index adfc08bc..f01907b6 100644 --- a/lib/src/models/recorder_settings.dart +++ b/lib/src/models/recorder_settings.dart @@ -54,5 +54,6 @@ class RecorderSettings { androidEncoderSettings.androidEncoder.toNativeFormat(), Constants.sampleRate: sampleRate, Constants.bitRate: bitRate, + Constants.androidStopTimeoutMs: androidEncoderSettings.stopTimeoutMs, }; }