Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,34 +177,36 @@ 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) {
val duration = getDuration(recorderSettings?.path)
val hashMap = HashMap<String, Any?>()
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) {
val args: MutableMap<String, Any?> = HashMap()
args[Constants.normalisedRms] = rms
args[Constants.bytes] = chunk
args[Constants.recordedDuration] = milliSeconds
Handler(Looper.getMainLooper()).post {
mainHandler.post {
channel.invokeMethod(Constants.onAudioChunk, args)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,18 @@ class CommonEncoder {
private val inputQueue = LinkedList<ByteArray>()

/** 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 */
Expand All @@ -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 */
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -244,7 +239,7 @@ class CommonEncoder {
isMuxerStarted = true
}
}
})
}, handler)

mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
mediaCodec.start()
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Flagging a dependency (no change strictly required): EOS is only queued here when currentInputBufferIndex >= 0 && inputQueue.isEmpty(). Otherwise it relies on a later onInputBufferAvailable to drain the queue and queue EOS. In the normal stop flow that holds (AudioRecord is stopped, no new input, queue drains). But if an input buffer is never returned, stopEncodercompletionCallback never fires and the Flutter stop() future hangs again — the original bug. Worth a comment/assertion documenting the assumption.

if (isEncoderStopped) return@post
if (currentInputBufferIndex >= 0 && inputQueue.isEmpty()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 currentInputBufferIndex, isEncodingComplete and totalBytesEncoded (declared at lines 58–76) are accessed from three threads: the recording thread (queueInputBuffer / feedEncoder), this handler thread, and the caller thread of signalToStop. The handler.post here adds a happens-before barrier for this path (good), but onInputBufferAvailable reads isEncodingComplete directly from the framework with no barrier, and queueInputBuffer reads currentInputBufferIndex on the recording thread unsynchronized. Recommend marking these fields @Volatile to make cross-thread visibility explicit.

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

/**
Expand All @@ -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
*
Expand All @@ -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}")
}
}
}

Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 mediaCodec.release() runs before quitSafely(), but quitSafely() still drains messages already queued on this handler before the thread quits. If an onInputBufferAvailable / feedEncoder message is already pending, it runs after release and calls getInputBuffer() on a released codec → IllegalStateException on the handler thread (uncaught — feedEncoder() has no try/catch). Consider handler.removeCallbacksAndMessages(null) before releasing the codec, or guarding the codec calls in feedEncoder. Low-probability, but the unguarded path exists.

}

// Reset state for next recording
Expand Down