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 @@ -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 {
/**
Expand All @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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,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)
}
}
}

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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/src/base/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
12 changes: 12 additions & 0 deletions lib/src/models/android_encoder_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions lib/src/models/recorder_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@ class RecorderSettings {
androidEncoderSettings.androidEncoder.toNativeFormat(),
Constants.sampleRate: sampleRate,
Constants.bitRate: bitRate,
Constants.androidStopTimeoutMs: androidEncoderSettings.stopTimeoutMs,
};
}