@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.
- package/android/build.gradle +15 -6
- package/android/src/main/AndroidManifest.xml +26 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/CameraSession.kt +252 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/Encoders.kt +222 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/GlCompositor.kt +755 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpNativeModule.kt +975 -135
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpPreviewView.kt +119 -23
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpService.kt +126 -0
- package/ios/PickleballRtmpNativeModule.swift +1161 -8
- package/package.json +3 -3
- package/src/PickleballRtmpNative.types.ts +140 -0
- package/src/PickleballRtmpNativeModule.ts +69 -0
package/android/build.gradle
CHANGED
|
@@ -16,15 +16,24 @@ android {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
repositories {
|
|
19
|
-
//
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
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, mà đ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
|
+
}
|