@pickleball/rtmp-native 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,15 +16,24 @@ android {
16
16
  }
17
17
 
18
18
  repositories {
19
- // HaishinKit.kt phát hành qua JitPack, không có trên Maven Central.
19
+ // RootEncoder phát hành qua JitPack, không có trên Maven Central.
20
20
  maven { url 'https://jitpack.io' }
21
21
  }
22
22
 
23
23
  dependencies {
24
24
  implementation 'androidx.core:core-ktx:1.15.0'
25
- // Bản Kotlin của HaishinKit. API KHÁC bản Swift: MediaMixer(context),
26
- // StreamSession.Builder(...).build(), mixer.registerOutput(...).
27
- implementation 'com.github.HaishinKit.HaishinKit~kt:haishinkit:0.18.2'
28
- // Module rtmp tách riêng thiếu thì không RtmpStreamSessionFactory.
29
- implementation 'com.github.HaishinKit.HaishinKit~kt:rtmp:0.18.2'
25
+ // CHỈ module `rtmp` của RootEncoder thuần giao thức đường truyền
26
+ // (RtmpClient: connect/setVideoInfo/sendVideo/sendAudio, nhận thẳng buffer
27
+ // của MediaCodec). KHÔNG dùng module `library` vì phần camera/GL của nó cũng
28
+ // đi qua một tầng OpenGL, đo thực tế cho thấy Camera2 trần hơn hẳn.
29
+ //
30
+ // Đã bỏ HaishinKit.kt: MediaMixer của nó lấy mẫu texture bằng GL_NEAREST và
31
+ // quên glBindTexture ở nhánh video, gây răng cưa trên cạnh chéo; vá cả hai
32
+ // vẫn không hết vì khuyết tật nằm ở kiến trúc render.
33
+ //
34
+ // GHIM 2.7.0, KHÔNG nâng lên 2.7.5+ hay 2.8.0: những bản đó biên dịch bằng
35
+ // Kotlin 2.3.x (metadata 2.3.0) trong khi compiler của Expo SDK 57 là 2.1.0,
36
+ // chỉ đọc được metadata tới 2.2.0 — nâng lên là hỏng toàn bộ stdlib với
37
+ // hàng loạt "Unresolved reference". 2.7.0 dùng Kotlin 2.2.21 → metadata 2.2.0.
38
+ implementation 'com.github.pedroSG94.RootEncoder:rtmp:2.7.0'
30
39
  }
@@ -2,4 +2,30 @@
2
2
  <uses-permission android:name="android.permission.CAMERA" />
3
3
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
4
4
  <uses-permission android:name="android.permission.INTERNET" />
5
+
6
+ <!--
7
+ Foreground service giữ tiến trình sống khi app ở nền (xem
8
+ PickleballRtmpService). Khai báo Ở ĐÂY chứ không phải ở manifest của app:
9
+ module là nơi sở hữu service, nên mọi app dùng module đều nhận đủ quyền qua
10
+ manifest merge — không ai phải nhớ chép tay.
11
+
12
+ POST_NOTIFICATIONS: Android 13+ cần quyền này để HIỆN thông báo. Thiếu quyền
13
+ thì service vẫn chạy đủ đặc quyền, chỉ là thông báo bị ẩn.
14
+ -->
15
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
16
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
17
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
18
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
19
+
20
+ <application>
21
+ <!--
22
+ foregroundServiceType PHẢI khớp với cờ truyền vào startForeground(), nếu
23
+ không Android 14+ ném IllegalArgumentException ngay lúc gọi.
24
+ exported=false: không thành phần ngoài nào được phép khởi động service này.
25
+ -->
26
+ <service
27
+ android:name=".PickleballRtmpService"
28
+ android:exported="false"
29
+ android:foregroundServiceType="camera|microphone" />
30
+ </application>
5
31
  </manifest>
@@ -0,0 +1,252 @@
1
+ package expo.modules.pickleballrtmp
2
+
3
+ import android.content.Context
4
+ import android.graphics.Rect
5
+ import android.hardware.camera2.CameraCaptureSession
6
+ import android.hardware.camera2.CameraCharacteristics
7
+ import android.hardware.camera2.CameraDevice
8
+ import android.hardware.camera2.CameraManager
9
+ import android.hardware.camera2.CameraMetadata
10
+ import android.hardware.camera2.CaptureRequest
11
+ import android.os.Build
12
+ import android.os.Handler
13
+ import android.os.HandlerThread
14
+ import android.util.Range
15
+ import android.view.Surface
16
+ import kotlinx.coroutines.suspendCancellableCoroutine
17
+ import kotlin.coroutines.resume
18
+ import kotlin.math.roundToInt
19
+
20
+ /**
21
+ * Sở hữu trực tiếp một session Camera2 ghi ra NHIỀU surface song song.
22
+ *
23
+ * Thay cho Camera2Controls.kt cũ — file đó phải reflection vào field private của
24
+ * Camera2Source (HaishinKit) vì thư viện không public builder/session, và mọi
25
+ * lệnh set rơi im lặng khi capture session chưa kịp tạo. Ở đây ta tự giữ
26
+ * `CaptureRequest.Builder` nên tất cả zoom/EV/khoá nét/khoá sáng/fps/đèn chỉ là
27
+ * key thường, đặt xong gọi lại setRepeatingRequest — không reflection, không
28
+ * đoán, lỗi thì biết ngay.
29
+ */
30
+ internal class CameraSession(private val context: Context) {
31
+ private var device: CameraDevice? = null
32
+ private var session: CameraCaptureSession? = null
33
+ private var builder: CaptureRequest.Builder? = null
34
+ private var thread: HandlerThread? = null
35
+ private var handler: Handler? = null
36
+ private var targets: List<Surface> = emptyList()
37
+
38
+ var cameraId: String = "0"
39
+ private set
40
+
41
+ private val manager: CameraManager
42
+ get() = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
43
+
44
+ fun characteristics(): CameraCharacteristics? =
45
+ runCatching { manager.getCameraCharacteristics(cameraId) }.getOrNull()
46
+
47
+ /**
48
+ * Chọn cỡ capture khớp yêu cầu. Ưu tiên khớp CHÍNH XÁC: mọi cỡ lệch đều buộc
49
+ * ai đó phải scale, và scale là thứ ta vừa bỏ công loại khỏi pipeline.
50
+ */
51
+ fun pickSize(id: String, width: Int, height: Int): Pair<Int, Int> {
52
+ val map = runCatching {
53
+ manager.getCameraCharacteristics(id)
54
+ .get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
55
+ }.getOrNull() ?: return width to height
56
+ val sizes = map.getOutputSizes(android.media.MediaCodec::class.java) ?: return width to height
57
+ sizes.firstOrNull { it.width == width && it.height == height }?.let {
58
+ return it.width to it.height
59
+ }
60
+ // Không có cỡ khớp: lấy cỡ cùng tỉ lệ, lớn hơn gần nhất.
61
+ val ratio = width.toDouble() / height
62
+ return sizes
63
+ .filter { it.width >= width && kotlin.math.abs(it.width.toDouble() / it.height - ratio) < 0.02 }
64
+ .minByOrNull { it.width.toLong() * it.height }
65
+ ?.let { it.width to it.height }
66
+ ?: (width to height)
67
+ }
68
+
69
+ suspend fun open(id: String, surfaces: List<Surface>, fps: Int) {
70
+ close()
71
+ cameraId = id
72
+ targets = surfaces
73
+ thread = HandlerThread("pickleball-camera").also {
74
+ it.start()
75
+ handler = Handler(it.looper)
76
+ }
77
+
78
+ val opened = suspendCancellableCoroutine { cont ->
79
+ runCatching {
80
+ manager.openCamera(
81
+ id,
82
+ object : CameraDevice.StateCallback() {
83
+ override fun onOpened(camera: CameraDevice) {
84
+ if (cont.isActive) cont.resume(Result.success(camera))
85
+ }
86
+
87
+ override fun onDisconnected(camera: CameraDevice) {
88
+ camera.close()
89
+ if (cont.isActive) {
90
+ cont.resume(Result.failure(RtmpException("camera $id bị ngắt")))
91
+ }
92
+ }
93
+
94
+ override fun onError(camera: CameraDevice, error: Int) {
95
+ camera.close()
96
+ if (cont.isActive) {
97
+ cont.resume(Result.failure(RtmpException("không mở được camera $id (mã $error)")))
98
+ }
99
+ }
100
+ },
101
+ handler,
102
+ )
103
+ }.onFailure { if (cont.isActive) cont.resume(Result.failure(it)) }
104
+ }.getOrThrow()
105
+ device = opened
106
+
107
+ @Suppress("DEPRECATION")
108
+ val configured = suspendCancellableCoroutine { cont ->
109
+ runCatching {
110
+ opened.createCaptureSession(
111
+ surfaces,
112
+ object : CameraCaptureSession.StateCallback() {
113
+ override fun onConfigured(value: CameraCaptureSession) {
114
+ if (cont.isActive) cont.resume(Result.success(value))
115
+ }
116
+
117
+ override fun onConfigureFailed(value: CameraCaptureSession) {
118
+ if (cont.isActive) {
119
+ cont.resume(Result.failure(RtmpException("không cấu hình được capture session")))
120
+ }
121
+ }
122
+ },
123
+ handler,
124
+ )
125
+ }.onFailure { if (cont.isActive) cont.resume(Result.failure(it)) }
126
+ }.getOrThrow()
127
+ session = configured
128
+
129
+ // TEMPLATE_RECORD: tuning cho video liên tục, khác TEMPLATE_PREVIEW ở chỗ
130
+ // ưu tiên fps ổn định hơn là độ trễ lấy nét.
131
+ builder = opened.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
132
+ surfaces.forEach { addTarget(it) }
133
+ setFpsRange(this, fps)
134
+ }
135
+ applyRepeating()
136
+ }
137
+
138
+ fun close() {
139
+ runCatching { session?.stopRepeating() }
140
+ runCatching { session?.close() }
141
+ session = null
142
+ runCatching { device?.close() }
143
+ device = null
144
+ builder = null
145
+ thread?.quitSafely()
146
+ thread = null
147
+ handler = null
148
+ targets = emptyList()
149
+ }
150
+
151
+ val isOpen: Boolean get() = session != null && builder != null
152
+
153
+ private fun applyRepeating(): Boolean = runCatching {
154
+ val active = session ?: return false
155
+ val request = builder ?: return false
156
+ active.setRepeatingRequest(request.build(), null, handler)
157
+ true
158
+ }.getOrDefault(false)
159
+
160
+ // MARK: - Điều khiển (CaptureRequest key thường, không reflection)
161
+
162
+ private fun setFpsRange(target: CaptureRequest.Builder, fps: Int) {
163
+ val ranges = characteristics()
164
+ ?.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) ?: return
165
+ val exact = ranges.firstOrNull { it.lower == fps && it.upper == fps }
166
+ ?: ranges.filter { fps in it.lower..it.upper }.minByOrNull { it.upper - it.lower }
167
+ ?: return
168
+ // Ghim LUÔN, kể cả 30: bỏ qua thì AE nằm ở range mặc định của HAL (đo trên
169
+ // Redmi Note 15 ra [15,30]) và thiếu sáng là tụt về 15fps, shutter dài gấp
170
+ // đôi, bóng nhoè — trong khi encoder vẫn chạy 30 và nhân đôi khung.
171
+ target.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(exact.lower, exact.upper))
172
+ }
173
+
174
+ fun setZoom(ratio: Float): Boolean {
175
+ val request = builder ?: return false
176
+ val chars = characteristics() ?: return false
177
+ if (Build.VERSION.SDK_INT >= 30) {
178
+ val range = chars.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE) ?: return false
179
+ request.set(CaptureRequest.CONTROL_ZOOM_RATIO, ratio.coerceIn(range.lower, range.upper))
180
+ } else {
181
+ val max = chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: return false
182
+ val active = chars.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) ?: return false
183
+ val clamped = ratio.coerceIn(1f, max)
184
+ val cropWidth = (active.width() / clamped).toInt()
185
+ val cropHeight = (active.height() / clamped).toInt()
186
+ val left = active.left + (active.width() - cropWidth) / 2
187
+ val top = active.top + (active.height() - cropHeight) / 2
188
+ request.set(
189
+ CaptureRequest.SCALER_CROP_REGION,
190
+ Rect(left, top, left + cropWidth, top + cropHeight),
191
+ )
192
+ }
193
+ return applyRepeating()
194
+ }
195
+
196
+ fun setExposureBias(ev: Float): Boolean {
197
+ val request = builder ?: return false
198
+ val chars = characteristics() ?: return false
199
+ val range = chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) ?: return false
200
+ val step = chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP)
201
+ ?.toFloat()?.takeIf { it > 0f } ?: return false
202
+ val index = (ev / step).roundToInt().coerceIn(range.lower, range.upper)
203
+ request.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, index)
204
+ return applyRepeating()
205
+ }
206
+
207
+ fun setExposureLocked(locked: Boolean): Boolean {
208
+ val request = builder ?: return false
209
+ request.set(CaptureRequest.CONTROL_AE_LOCK, locked)
210
+ return applyRepeating()
211
+ }
212
+
213
+ /**
214
+ * Chống chớp đèn: mặc định HAL là AUTO — ROM đoán tần số lưới điện, và HyperOS
215
+ * đoán trật 60Hz trên lưới 50Hz là chuyện thường → AE chọn shutter lệch pha
216
+ * với nhịp đèn 100Hz, cả khung phập phồng. Ghim 50HZ là hết. Lưu ý: antibanding
217
+ * chỉ ràng buộc khi AE còn tự chạy — CONTROL_AE_LOCK bật thì giữ nguyên shutter
218
+ * đang có, khoá lúc đang lệch pha là khoá luôn cái chớp.
219
+ */
220
+ fun setAntibanding(mode: Int): Boolean {
221
+ val request = builder ?: return false
222
+ val modes = characteristics()
223
+ ?.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_ANTIBANDING_MODES)
224
+ if (modes?.contains(mode) != true) return false
225
+ request.set(CaptureRequest.CONTROL_AE_ANTIBANDING_MODE, mode)
226
+ return applyRepeating()
227
+ }
228
+
229
+ /** Khoá nét = chuyển về AUTO (dừng đảo nét); mở khoá = CONTINUOUS_VIDEO. */
230
+ fun setFocusLocked(locked: Boolean): Boolean {
231
+ val request = builder ?: return false
232
+ val modes = characteristics()?.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES)
233
+ ?: return false
234
+ val target =
235
+ if (locked) CameraMetadata.CONTROL_AF_MODE_AUTO
236
+ else CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO
237
+ if (!modes.contains(target)) return false
238
+ request.set(CaptureRequest.CONTROL_AF_MODE, target)
239
+ return applyRepeating()
240
+ }
241
+
242
+ fun setTorch(enabled: Boolean): Boolean {
243
+ val request = builder ?: return false
244
+ val available = characteristics()?.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false
245
+ if (!available) return false
246
+ request.set(
247
+ CaptureRequest.FLASH_MODE,
248
+ if (enabled) CameraMetadata.FLASH_MODE_TORCH else CameraMetadata.FLASH_MODE_OFF,
249
+ )
250
+ return applyRepeating()
251
+ }
252
+ }
@@ -0,0 +1,222 @@
1
+ package expo.modules.pickleballrtmp
2
+
3
+ import android.media.AudioFormat
4
+ import android.media.AudioRecord
5
+ import android.media.MediaCodec
6
+ import android.media.MediaCodecInfo
7
+ import android.media.MediaFormat
8
+ import android.media.MediaRecorder
9
+ import android.os.Bundle
10
+ import android.view.Surface
11
+ import java.nio.ByteBuffer
12
+
13
+ /**
14
+ * Encoder H.264 nhận khung qua INPUT SURFACE — Camera2 ghi thẳng vào đó.
15
+ *
16
+ * Đây là điểm mấu chốt của kiến trúc mới: không có bước copy hay compose nào
17
+ * giữa cảm biến và encoder. Camera2 cho phép một capture session ghi ra nhiều
18
+ * surface song song, nên preview và encoder nhận CÙNG khung hình từ ISP.
19
+ */
20
+ internal class VideoEncoder {
21
+ private var codec: MediaCodec? = null
22
+ private var drainThread: Thread? = null
23
+ @Volatile private var draining = false
24
+ @Volatile private var frameCount = 0L
25
+
26
+ var inputSurface: Surface? = null
27
+ private set
28
+
29
+ /** Số khung đã encode — dùng cho getStats().currentFps. */
30
+ val encodedFrames: Long get() = frameCount
31
+
32
+ fun prepare(width: Int, height: Int, fps: Int, bitrate: Int) {
33
+ release()
34
+ frameCount = 0
35
+ val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
36
+ setInteger(
37
+ MediaFormat.KEY_COLOR_FORMAT,
38
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
39
+ )
40
+ setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
41
+ setInteger(MediaFormat.KEY_FRAME_RATE, fps)
42
+ // Keyframe 2 giây, khớp ranh giới segment HLS phía MediaMTX.
43
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
44
+ // CBR chứ không VBR mặc định: đo thực tế bản thử ra 7.47 Mbps khi đặt 6,
45
+ // và ở sân WiFi yếu thì vọt bitrate là nghẽn sớm hơn tính toán.
46
+ setInteger(
47
+ MediaFormat.KEY_BITRATE_MODE,
48
+ MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR,
49
+ )
50
+ }
51
+ codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC).apply {
52
+ configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
53
+ inputSurface = createInputSurface()
54
+ }
55
+ }
56
+
57
+ fun start(
58
+ onHeader: (sps: ByteBuffer, pps: ByteBuffer?) -> Unit,
59
+ onFrame: (ByteBuffer, MediaCodec.BufferInfo) -> Unit,
60
+ ) {
61
+ val active = codec ?: return
62
+ active.start()
63
+ draining = true
64
+ drainThread = Thread {
65
+ val info = MediaCodec.BufferInfo()
66
+ while (draining) {
67
+ val index = try {
68
+ active.dequeueOutputBuffer(info, 10_000)
69
+ } catch (error: IllegalStateException) {
70
+ break
71
+ }
72
+ when {
73
+ index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
74
+ // Lấy SPS/PPS từ output format thay vì tự bóc start code trong
75
+ // buffer BUFFER_FLAG_CODEC_CONFIG — ít chỗ sai hơn.
76
+ val format = active.outputFormat
77
+ val sps = format.getByteBuffer("csd-0")
78
+ val pps = format.getByteBuffer("csd-1")
79
+ if (sps != null) onHeader(sps, pps)
80
+ }
81
+
82
+ index >= 0 -> {
83
+ val buffer = runCatching { active.getOutputBuffer(index) }.getOrNull()
84
+ val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
85
+ if (buffer != null && !isConfig && info.size > 0) {
86
+ frameCount++
87
+ onFrame(buffer, info)
88
+ }
89
+ runCatching { active.releaseOutputBuffer(index, false) }
90
+ }
91
+ }
92
+ }
93
+ }.also { it.start() }
94
+ }
95
+
96
+ /** Xin IDR ngay — để luồng bắt đầu bằng keyframe chứ không chờ tới 2 giây. */
97
+ fun requestKeyframe() {
98
+ runCatching {
99
+ codec?.setParameters(
100
+ Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) },
101
+ )
102
+ }
103
+ }
104
+
105
+ /** Đổi bitrate giữa luồng — KHÔNG khởi động lại encoder (sẽ thủng segment HLS). */
106
+ fun setBitrate(bitsPerSecond: Int) {
107
+ runCatching {
108
+ codec?.setParameters(
109
+ Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bitsPerSecond) },
110
+ )
111
+ }
112
+ }
113
+
114
+ fun release() {
115
+ draining = false
116
+ drainThread?.join(1_000)
117
+ drainThread = null
118
+ runCatching { codec?.stop() }
119
+ runCatching { codec?.release() }
120
+ codec = null
121
+ runCatching { inputSurface?.release() }
122
+ inputSurface = null
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Mic → AAC. Tách hẳn khỏi video: tắt mic là dừng thu và dừng encode luôn, chứ
128
+ * không mute — giữ đúng hành vi cũ (`setMicrophoneEnabled` gỡ nguồn audio).
129
+ */
130
+ internal class AudioEncoder {
131
+ private var codec: MediaCodec? = null
132
+ private var record: AudioRecord? = null
133
+ private var thread: Thread? = null
134
+ @Volatile private var running = false
135
+
136
+ fun prepare(bitrate: Int) {
137
+ release()
138
+ val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNELS)
139
+ .apply {
140
+ setInteger(
141
+ MediaFormat.KEY_AAC_PROFILE,
142
+ MediaCodecInfo.CodecProfileLevel.AACObjectLC,
143
+ )
144
+ setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
145
+ setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, BUFFER_SIZE)
146
+ }
147
+ codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
148
+ configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
149
+ }
150
+ }
151
+
152
+ @Suppress("MissingPermission")
153
+ fun start(onFrame: (ByteBuffer, MediaCodec.BufferInfo) -> Unit) {
154
+ val active = codec ?: return
155
+ val minBuffer = AudioRecord.getMinBufferSize(
156
+ SAMPLE_RATE,
157
+ AudioFormat.CHANNEL_IN_MONO,
158
+ AudioFormat.ENCODING_PCM_16BIT,
159
+ ).coerceAtLeast(BUFFER_SIZE)
160
+ record = AudioRecord(
161
+ MediaRecorder.AudioSource.MIC,
162
+ SAMPLE_RATE,
163
+ AudioFormat.CHANNEL_IN_MONO,
164
+ AudioFormat.ENCODING_PCM_16BIT,
165
+ minBuffer,
166
+ )
167
+ active.start()
168
+ record?.startRecording()
169
+ running = true
170
+ thread = Thread {
171
+ val info = MediaCodec.BufferInfo()
172
+ val pcm = ByteArray(BUFFER_SIZE)
173
+ while (running) {
174
+ val read = record?.read(pcm, 0, pcm.size) ?: break
175
+ if (read > 0) {
176
+ val inputIndex = runCatching { active.dequeueInputBuffer(10_000) }.getOrDefault(-1)
177
+ if (inputIndex >= 0) {
178
+ runCatching {
179
+ val input = active.getInputBuffer(inputIndex)
180
+ input?.clear()
181
+ input?.put(pcm, 0, read)
182
+ active.queueInputBuffer(
183
+ inputIndex,
184
+ 0,
185
+ read,
186
+ System.nanoTime() / 1_000,
187
+ 0,
188
+ )
189
+ }
190
+ }
191
+ }
192
+ var outputIndex = runCatching { active.dequeueOutputBuffer(info, 0) }.getOrDefault(-1)
193
+ while (outputIndex >= 0) {
194
+ val buffer = runCatching { active.getOutputBuffer(outputIndex) }.getOrNull()
195
+ val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
196
+ if (buffer != null && !isConfig && info.size > 0) onFrame(buffer, info)
197
+ runCatching { active.releaseOutputBuffer(outputIndex, false) }
198
+ outputIndex = runCatching { active.dequeueOutputBuffer(info, 0) }.getOrDefault(-1)
199
+ }
200
+ }
201
+ }.also { it.start() }
202
+ }
203
+
204
+ fun release() {
205
+ running = false
206
+ thread?.join(1_000)
207
+ thread = null
208
+ runCatching { record?.stop() }
209
+ runCatching { record?.release() }
210
+ record = null
211
+ runCatching { codec?.stop() }
212
+ runCatching { codec?.release() }
213
+ codec = null
214
+ }
215
+
216
+ companion object {
217
+ const val SAMPLE_RATE = 44_100
218
+ const val CHANNELS = 1
219
+ const val IS_STEREO = false
220
+ private const val BUFFER_SIZE = 4_096
221
+ }
222
+ }