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/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 182a7fa8..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 @@ -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,40 @@ 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}") + } + } 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() + } + }, recorderSettings.stopTimeoutMs) + } + } } /** @@ -288,6 +317,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 +346,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 +454,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 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, }; }