From d03cb0cec3daf4c98e11ce31c34e8ea8dd9e5c19 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 4 Mar 2026 01:56:33 +0100 Subject: [PATCH 1/3] change record controller to async mode --- .../main/java/com/pedro/common/Extensions.kt | 11 +- .../java/com/pedro/common/frame/MediaFrame.kt | 3 +- .../base/recording/BaseRecordController.java | 2 +- .../util/AndroidMuxerRecordController.java | 172 -------------- .../util/AndroidMuxerRecordController.kt | 213 ++++++++++++++++++ 5 files changed, 226 insertions(+), 175 deletions(-) delete mode 100644 library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.java create mode 100644 library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt diff --git a/common/src/main/java/com/pedro/common/Extensions.kt b/common/src/main/java/com/pedro/common/Extensions.kt index 5d549aceb4..8b194b4df7 100644 --- a/common/src/main/java/com/pedro/common/Extensions.kt +++ b/common/src/main/java/com/pedro/common/Extensions.kt @@ -149,7 +149,16 @@ fun Throwable.validMessage(): String { return (message ?: "").ifEmpty { javaClass.simpleName } } -fun MediaCodec.BufferInfo.toMediaFrameInfo() = MediaFrame.Info(offset, size, presentationTimeUs, isKeyframe()) +fun MediaCodec.BufferInfo.toMediaFrameInfo() = MediaFrame.Info(offset, size, presentationTimeUs, isKeyframe(), flags) + +fun MediaFrame.Info.toMediaCodecBufferInfo() = MediaCodec.BufferInfo().apply { + set( + this@toMediaCodecBufferInfo.offset, + this@toMediaCodecBufferInfo.size, + this@toMediaCodecBufferInfo.timestamp, + this@toMediaCodecBufferInfo.flags + ) +} fun ByteBuffer.clone(): ByteBuffer = ByteBuffer.wrap(toByteArray()) diff --git a/common/src/main/java/com/pedro/common/frame/MediaFrame.kt b/common/src/main/java/com/pedro/common/frame/MediaFrame.kt index 32529c8709..2c3d0024e1 100644 --- a/common/src/main/java/com/pedro/common/frame/MediaFrame.kt +++ b/common/src/main/java/com/pedro/common/frame/MediaFrame.kt @@ -11,7 +11,8 @@ data class MediaFrame( val offset: Int, val size: Int, val timestamp: Long, - val isKeyFrame: Boolean + val isKeyFrame: Boolean, + val flags: Int ) enum class Type { diff --git a/library/src/main/java/com/pedro/library/base/recording/BaseRecordController.java b/library/src/main/java/com/pedro/library/base/recording/BaseRecordController.java index 5ec6506f28..0a941fa974 100644 --- a/library/src/main/java/com/pedro/library/base/recording/BaseRecordController.java +++ b/library/src/main/java/com/pedro/library/base/recording/BaseRecordController.java @@ -30,7 +30,7 @@ public abstract class BaseRecordController implements RecordController { protected static final String TAG = "RecordController"; - protected Status status = Status.STOPPED; + protected volatile Status status = Status.STOPPED; protected VideoCodec videoCodec = VideoCodec.H264; protected AudioCodec audioCodec = AudioCodec.AAC; protected long pauseMoment = 0; diff --git a/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.java b/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.java deleted file mode 100644 index 1407209bad..0000000000 --- a/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (C) 2024 pedroSG94. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.pedro.library.util; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.media.MediaMuxer; -import android.os.Build; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; - -import com.pedro.common.AudioCodec; -import com.pedro.common.BitrateManager; -import com.pedro.common.ExtensionsKt; -import com.pedro.library.base.recording.BaseRecordController; - -import java.io.FileDescriptor; -import java.io.IOException; -import java.nio.ByteBuffer; - -/** - * Created by pedro on 08/03/19. - * Class to control video recording with MediaMuxer. - */ -@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) -public class AndroidMuxerRecordController extends BaseRecordController { - - private MediaMuxer mediaMuxer; - private MediaFormat videoFormat, audioFormat; - private final int outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4; - - @Override - public void startRecord(@NonNull String path, @Nullable Listener listener, RecordTracks tracks) throws IOException { - this.tracks = tracks; - if (audioCodec != AudioCodec.AAC) { - throw new IOException("Unsupported AudioCodec: " + audioCodec.name()); - } - mediaMuxer = new MediaMuxer(path, outputFormat); - this.listener = listener; - status = Status.STARTED; - if (listener != null) { - bitrateManager = new BitrateManager(listener); - listener.onStatusChange(status); - } else { - bitrateManager = null; - } - if (tracks == RecordTracks.AUDIO && audioFormat != null) init(); - } - - @Override - @RequiresApi(api = Build.VERSION_CODES.O) - public void startRecord(@NonNull FileDescriptor fd, @Nullable Listener listener, RecordTracks tracks) throws IOException { - this.tracks = tracks; - if (audioCodec != AudioCodec.AAC) { - throw new IOException("Unsupported AudioCodec: " + audioCodec.name()); - } - mediaMuxer = new MediaMuxer(fd, outputFormat); - this.listener = listener; - status = Status.STARTED; - if (listener != null) { - bitrateManager = new BitrateManager(listener); - listener.onStatusChange(status); - } else { - bitrateManager = null; - } - if(tracks == RecordTracks.AUDIO && audioFormat != null) init(); - } - - @Override - public void stopRecord() { - videoTrack = -1; - audioTrack = -1; - status = Status.STOPPED; - if (mediaMuxer != null) { - try { - mediaMuxer.stop(); - mediaMuxer.release(); - } catch (Exception ignored) { - } - } - mediaMuxer = null; - pauseMoment = 0; - pauseTime = 0; - startTs = 0; - requestKeyFrame = null; - if (listener != null) listener.onStatusChange(status); - } - - @Override - public void recordVideo(ByteBuffer videoBuffer, MediaCodec.BufferInfo videoInfo) { - if (status == Status.STARTED && videoFormat != null && (audioFormat != null || tracks == RecordTracks.VIDEO)) { - if (videoInfo.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME || isKeyFrame(videoBuffer)) { - requestKeyFrame = null; - videoTrack = mediaMuxer.addTrack(videoFormat); - init(); - } else if (requestKeyFrame != null) { - requestKeyFrame.onRequestKeyFrame(); - requestKeyFrame = null; - } - } else if (status == Status.RESUMED && (videoInfo.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME - || isKeyFrame(videoBuffer))) { - status = Status.RECORDING; - if (listener != null) listener.onStatusChange(status); - } - if (status == Status.RECORDING && tracks != RecordTracks.AUDIO) { - write(videoTrack, videoBuffer, videoInfo); - } - } - - @Override - public void recordAudio(ByteBuffer audioBuffer, MediaCodec.BufferInfo audioInfo) { - if (status == Status.RECORDING && tracks != RecordTracks.VIDEO) { - write(audioTrack, audioBuffer, audioInfo); - } - } - - @Override - public void setVideoFormat(MediaFormat videoFormat) { - this.videoFormat = videoFormat; - } - - @Override - public void setAudioFormat(MediaFormat audioFormat) { - this.audioFormat = audioFormat; - if (tracks == RecordTracks.AUDIO && status == Status.STARTED) { - init(); - } - } - - @Override - public void resetFormats() { - videoFormat = null; - audioFormat = null; - } - - private void init() { - if (tracks != RecordTracks.VIDEO) audioTrack = mediaMuxer.addTrack(audioFormat); - mediaMuxer.start(); - status = Status.RECORDING; - if (listener != null) listener.onStatusChange(status); - } - - private void write(int track, ByteBuffer byteBuffer, MediaCodec.BufferInfo info) { - if (track == -1) return; - String trackString = track == audioTrack ? "Audio" : "Video"; - try { - MediaCodec.BufferInfo i = updateFormat(info); - Log.i(TAG, trackString + ", ts: " + i.presentationTimeUs + ", flag: " + i.flags); - mediaMuxer.writeSampleData(track, byteBuffer, i); - if (bitrateManager != null) bitrateManager.calculateBitrate(i.size * 8L, ExtensionsKt.getSuspendContext()); - } catch (Exception e) { - if (listener != null) listener.onError(e); - } - } -} \ No newline at end of file diff --git a/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt b/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt new file mode 100644 index 0000000000..04258c2df4 --- /dev/null +++ b/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.pedro.library.util + +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.pedro.common.AudioCodec +import com.pedro.common.BitrateManager +import com.pedro.common.clone +import com.pedro.common.frame.MediaFrame +import com.pedro.common.toMediaCodecBufferInfo +import com.pedro.common.toMediaFrameInfo +import com.pedro.library.base.recording.BaseRecordController +import com.pedro.library.base.recording.RecordController +import com.pedro.library.base.recording.RecordController.RecordTracks +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.FileDescriptor +import java.io.IOException +import java.nio.ByteBuffer +import kotlin.math.max + +/** + * Created by pedro on 08/03/19. + * Class to control video recording with MediaMuxer. + */ +@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) +class AndroidMuxerRecordController : BaseRecordController() { + private var mediaMuxer: MediaMuxer? = null + private var videoFormat: MediaFormat? = null + private var audioFormat: MediaFormat? = null + private val outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4 + private val scope = CoroutineScope(Dispatchers.IO) + private var muxerChannel: Channel? = null + private var muxerJob: Job? = null + + @Throws(IOException::class) + override fun startRecord( + path: String, + listener: RecordController.Listener?, + tracks: RecordTracks + ) { + this.tracks = tracks + if (audioCodec != AudioCodec.AAC) { + throw IOException("Unsupported AudioCodec: " + audioCodec.name) + } + mediaMuxer = MediaMuxer(path, outputFormat) + this.listener = listener + status = RecordController.Status.STARTED + if (listener != null) { + bitrateManager = BitrateManager(listener) + listener.onStatusChange(status) + } else { + bitrateManager = null + } + startChannel() + if (tracks == RecordTracks.AUDIO && audioFormat != null) init() + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Throws(IOException::class) + override fun startRecord( + fd: FileDescriptor, + listener: RecordController.Listener?, + tracks: RecordTracks + ) { + this.tracks = tracks + if (audioCodec != AudioCodec.AAC) { + throw IOException("Unsupported AudioCodec: " + audioCodec.name) + } + mediaMuxer = MediaMuxer(fd, outputFormat) + this.listener = listener + status = RecordController.Status.STARTED + if (listener != null) { + bitrateManager = BitrateManager(listener) + listener.onStatusChange(status) + } else { + bitrateManager = null + } + startChannel() + if (tracks == RecordTracks.AUDIO && audioFormat != null) init() + } + + override fun stopRecord() { + videoTrack = -1 + audioTrack = -1 + status = RecordController.Status.STOPPED + muxerChannel?.close() + muxerChannel = null + runBlocking { muxerJob?.join() } + try { + mediaMuxer?.stop() + mediaMuxer?.release() + } catch (_: Exception) { } + mediaMuxer = null + pauseMoment = 0 + pauseTime = 0 + startTs = 0 + requestKeyFrame = null + if (listener != null) listener.onStatusChange(status) + } + + private fun startChannel() { + muxerChannel = Channel(Channel.UNLIMITED) + muxerJob = scope.launch { + val channel = muxerChannel ?: return@launch + for (frame in channel) { + when (frame.type) { + MediaFrame.Type.VIDEO -> { + if (status == RecordController.Status.STARTED && videoFormat != null && (audioFormat != null || tracks == RecordTracks.VIDEO)) { + if (frame.info.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME || isKeyFrame(frame.data)) { + requestKeyFrame = null + videoTrack = mediaMuxer?.addTrack(videoFormat!!) ?: -1 + init() + } else if (requestKeyFrame != null) { + requestKeyFrame.onRequestKeyFrame() + requestKeyFrame = null + } + } else if (status == RecordController.Status.RESUMED && (frame.info.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME + || isKeyFrame(frame.data)) + ) { + status = RecordController.Status.RECORDING + if (listener != null) listener.onStatusChange(status) + } + if (status == RecordController.Status.RECORDING && tracks != RecordTracks.AUDIO) { + write(videoTrack, frame) + } + } + MediaFrame.Type.AUDIO -> { + if (status == RecordController.Status.RECORDING && tracks != RecordTracks.VIDEO) { + write(audioTrack, frame) + } + } + } + } + + } + } + + override fun recordVideo(videoBuffer: ByteBuffer, videoInfo: MediaCodec.BufferInfo) { + val info = videoInfo.toMediaFrameInfo() + val i = updateFormat(info) + muxerChannel?.trySend(MediaFrame(videoBuffer.clone(), i, MediaFrame.Type.VIDEO)) + } + + override fun recordAudio(audioBuffer: ByteBuffer, audioInfo: MediaCodec.BufferInfo) { + val info = audioInfo.toMediaFrameInfo() + val i = updateFormat(info) + muxerChannel?.trySend(MediaFrame(audioBuffer.clone(), i, MediaFrame.Type.AUDIO)) + } + + override fun setVideoFormat(videoFormat: MediaFormat?) { + this.videoFormat = videoFormat + } + + override fun setAudioFormat(audioFormat: MediaFormat?) { + this.audioFormat = audioFormat + if (tracks == RecordTracks.AUDIO && status == RecordController.Status.STARTED) { + init() + } + } + + override fun resetFormats() { + videoFormat = null + audioFormat = null + } + + private fun init() { + if (tracks != RecordTracks.VIDEO) audioTrack = mediaMuxer?.addTrack(audioFormat!!) ?: -1 + mediaMuxer?.start() + status = RecordController.Status.RECORDING + if (listener != null) listener.onStatusChange(status) + } + + private suspend fun write(track: Int, frame: MediaFrame) { + if (track == -1) return + val trackString = if (track == audioTrack) "Audio" else "Video" + try { + Log.i(TAG, trackString + ", ts: " + frame.info.timestamp + ", flag: " + frame.info.flags) + mediaMuxer?.writeSampleData(track, frame.data, frame.info.toMediaCodecBufferInfo()) + if (bitrateManager != null) bitrateManager.calculateBitrate(frame.info.size * 8L) + } catch (e: Exception) { + if (listener != null) listener.onError(e) + } + } + + private fun updateFormat(oldInfo: MediaFrame.Info): MediaFrame.Info { + if (startTs <= 0) startTs = oldInfo.timestamp + val ts = max(0, oldInfo.timestamp - startTs - pauseTime) + return oldInfo.copy(timestamp = ts) + } +} \ No newline at end of file From 087eb3e6582643fac0b07cea6645d2ea0b487e38 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 4 Mar 2026 02:56:11 +0100 Subject: [PATCH 2/3] fix tests --- .../src/test/java/com/pedro/common/ExtensionTest.kt | 2 +- .../java/com/pedro/common/StreamBlockingQueueTest.kt | 2 +- .../library/util/AndroidMuxerRecordController.kt | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/common/src/test/java/com/pedro/common/ExtensionTest.kt b/common/src/test/java/com/pedro/common/ExtensionTest.kt index 55fcc1f6ab..d56606983e 100644 --- a/common/src/test/java/com/pedro/common/ExtensionTest.kt +++ b/common/src/test/java/com/pedro/common/ExtensionTest.kt @@ -31,7 +31,7 @@ class ExtensionTest { val buffer = ByteBuffer.wrap(ByteArray(256) { 0x00 }.mapIndexed { index, byte -> index.toByte() }.toByteArray()) val offset = 4 val minusLimit = 2 - val info = MediaFrame.Info(4, buffer.remaining() - minusLimit, 0, false) + val info = MediaFrame.Info(4, buffer.remaining() - minusLimit, 0, false, 0) val result = buffer.removeInfo(info) assertEquals(buffer.capacity() - offset - minusLimit, result.remaining()) assertEquals(offset.toByte(), result.get(0)) diff --git a/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt b/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt index 97ef4759a4..6b2bb1706a 100644 --- a/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt +++ b/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt @@ -31,7 +31,7 @@ class StreamBlockingQueueTest { @Test fun checkItemLimit() { val queue = StreamBlockingQueue(50) - val frame = MediaFrame(ByteBuffer.wrap(byteArrayOf()), MediaFrame.Info(0, 0, 0L, false), MediaFrame.Type.VIDEO) + val frame = MediaFrame(ByteBuffer.wrap(byteArrayOf()), MediaFrame.Info(0, 0, 0L, false, 0), MediaFrame.Type.VIDEO) (0..60).forEach { i -> queue.trySend(frame) } diff --git a/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt b/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt index 04258c2df4..53cf21057d 100644 --- a/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt +++ b/library/src/main/java/com/pedro/library/util/AndroidMuxerRecordController.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.FileDescriptor @@ -159,11 +160,22 @@ class AndroidMuxerRecordController : BaseRecordController() { } override fun recordVideo(videoBuffer: ByteBuffer, videoInfo: MediaCodec.BufferInfo) { + val info = videoInfo.toMediaFrameInfo() val i = updateFormat(info) muxerChannel?.trySend(MediaFrame(videoBuffer.clone(), i, MediaFrame.Type.VIDEO)) } + var job: Job? = null + + fun start() { + job = scope.launch { + while (isActive) { + + } + } + } + override fun recordAudio(audioBuffer: ByteBuffer, audioInfo: MediaCodec.BufferInfo) { val info = audioInfo.toMediaFrameInfo() val i = updateFormat(info) From 2ff795705f622f28f9355a18a0ec76215935ed80 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 4 Mar 2026 03:02:11 +0100 Subject: [PATCH 3/3] fixed tests --- common/src/main/java/com/pedro/common/frame/MediaFrame.kt | 2 +- common/src/test/java/com/pedro/common/ExtensionTest.kt | 2 +- .../src/test/java/com/pedro/common/StreamBlockingQueueTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/com/pedro/common/frame/MediaFrame.kt b/common/src/main/java/com/pedro/common/frame/MediaFrame.kt index 2c3d0024e1..a249d671a3 100644 --- a/common/src/main/java/com/pedro/common/frame/MediaFrame.kt +++ b/common/src/main/java/com/pedro/common/frame/MediaFrame.kt @@ -12,7 +12,7 @@ data class MediaFrame( val size: Int, val timestamp: Long, val isKeyFrame: Boolean, - val flags: Int + val flags: Int = 0 ) enum class Type { diff --git a/common/src/test/java/com/pedro/common/ExtensionTest.kt b/common/src/test/java/com/pedro/common/ExtensionTest.kt index d56606983e..55fcc1f6ab 100644 --- a/common/src/test/java/com/pedro/common/ExtensionTest.kt +++ b/common/src/test/java/com/pedro/common/ExtensionTest.kt @@ -31,7 +31,7 @@ class ExtensionTest { val buffer = ByteBuffer.wrap(ByteArray(256) { 0x00 }.mapIndexed { index, byte -> index.toByte() }.toByteArray()) val offset = 4 val minusLimit = 2 - val info = MediaFrame.Info(4, buffer.remaining() - minusLimit, 0, false, 0) + val info = MediaFrame.Info(4, buffer.remaining() - minusLimit, 0, false) val result = buffer.removeInfo(info) assertEquals(buffer.capacity() - offset - minusLimit, result.remaining()) assertEquals(offset.toByte(), result.get(0)) diff --git a/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt b/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt index 6b2bb1706a..97ef4759a4 100644 --- a/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt +++ b/common/src/test/java/com/pedro/common/StreamBlockingQueueTest.kt @@ -31,7 +31,7 @@ class StreamBlockingQueueTest { @Test fun checkItemLimit() { val queue = StreamBlockingQueue(50) - val frame = MediaFrame(ByteBuffer.wrap(byteArrayOf()), MediaFrame.Info(0, 0, 0L, false, 0), MediaFrame.Type.VIDEO) + val frame = MediaFrame(ByteBuffer.wrap(byteArrayOf()), MediaFrame.Info(0, 0, 0L, false), MediaFrame.Type.VIDEO) (0..60).forEach { i -> queue.trySend(frame) }