@clarionhq/recorder 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/android/build.gradle +97 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/cpp/cpp-adapter.cpp +7 -0
- package/android/src/main/java/com/clarionhq/recorder/AudioLevelMeter.kt +38 -0
- package/android/src/main/java/com/clarionhq/recorder/ClarionRecorderPackage.kt +27 -0
- package/android/src/main/java/com/clarionhq/recorder/EncodeFile.kt +62 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderConfig.kt +33 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderConstants.kt +16 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderSession.kt +336 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderTypes.kt +29 -0
- package/android/src/main/java/com/margelo/nitro/clarion/recorder/HybridClarionRecorder.kt +174 -0
- package/clarionhq-recorder.podspec +31 -0
- package/ios/AudioLevelMeter.swift +37 -0
- package/ios/EncodeFile.swift +69 -0
- package/ios/HybridClarionRecorder.swift +186 -0
- package/ios/RecorderConstants.swift +11 -0
- package/ios/RecorderSession.swift +278 -0
- package/ios/RecorderTypes.swift +41 -0
- package/lib/RecorderEngine.d.ts +31 -0
- package/lib/RecorderEngine.d.ts.map +1 -0
- package/lib/RecorderEngine.js +245 -0
- package/lib/RecorderEngine.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -0
- package/lib/native.d.ts +3 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +3 -0
- package/lib/native.js.map +1 -0
- package/lib/specs/ClarionRecorder.nitro.d.ts +49 -0
- package/lib/specs/ClarionRecorder.nitro.d.ts.map +1 -0
- package/lib/specs/ClarionRecorder.nitro.js +2 -0
- package/lib/specs/ClarionRecorder.nitro.js.map +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/android/ClarionRecorder+autolinking.cmake +81 -0
- package/nitrogen/generated/android/ClarionRecorder+autolinking.gradle +27 -0
- package/nitrogen/generated/android/ClarionRecorderOnLoad.cpp +62 -0
- package/nitrogen/generated/android/ClarionRecorderOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JFunc_void_NativeRecorderError.hpp +78 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string_double_double_double.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.cpp +207 -0
- package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JNativeRecorderConfig.hpp +90 -0
- package/nitrogen/generated/android/c++/JNativeRecorderError.hpp +65 -0
- package/nitrogen/generated/android/c++/JNativeRecorderResult.hpp +77 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/ClarionRecorderOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_NativeRecorderError.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string_double_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/HybridClarionRecorderSpec.kt +125 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderConfig.kt +91 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderError.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderResult.kt +76 -0
- package/nitrogen/generated/ios/ClarionRecorder+autolinking.rb +62 -0
- package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.cpp +89 -0
- package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.hpp +297 -0
- package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Umbrella.hpp +56 -0
- package/nitrogen/generated/ios/ClarionRecorderAutolinking.mm +33 -0
- package/nitrogen/generated/ios/ClarionRecorderAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.hpp +188 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_NativeRecorderError.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_NativeRecorderResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string_double_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec_cxx.swift +354 -0
- package/nitrogen/generated/ios/swift/NativeRecorderConfig.swift +108 -0
- package/nitrogen/generated/ios/swift/NativeRecorderError.swift +39 -0
- package/nitrogen/generated/ios/swift/NativeRecorderResult.swift +54 -0
- package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.cpp +34 -0
- package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.hpp +84 -0
- package/nitrogen/generated/shared/c++/NativeRecorderConfig.hpp +116 -0
- package/nitrogen/generated/shared/c++/NativeRecorderError.hpp +91 -0
- package/nitrogen/generated/shared/c++/NativeRecorderResult.hpp +103 -0
- package/package.json +66 -8
- package/src/RecorderEngine.ts +298 -0
- package/src/index.ts +8 -0
- package/src/native.ts +5 -0
- package/src/specs/ClarionRecorder.nitro.ts +58 -0
- package/index.js +0 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
package com.clarionhq.recorder
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.media.AudioFormat
|
|
7
|
+
import android.media.AudioRecord
|
|
8
|
+
import android.media.MediaCodec
|
|
9
|
+
import android.media.MediaRecorder
|
|
10
|
+
import androidx.core.content.ContextCompat
|
|
11
|
+
import java.io.File
|
|
12
|
+
import java.nio.ByteBuffer
|
|
13
|
+
import java.util.concurrent.LinkedBlockingQueue
|
|
14
|
+
import java.util.concurrent.TimeUnit
|
|
15
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
16
|
+
import kotlin.math.max
|
|
17
|
+
|
|
18
|
+
internal class RecorderSession(
|
|
19
|
+
private val context: Context,
|
|
20
|
+
private val config: RecorderConfig,
|
|
21
|
+
private val callbacks: RecorderCallbacks,
|
|
22
|
+
) {
|
|
23
|
+
private val channelMask = if (config.channels == 1) {
|
|
24
|
+
AudioFormat.CHANNEL_IN_MONO
|
|
25
|
+
} else {
|
|
26
|
+
AudioFormat.CHANNEL_IN_STEREO
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private val encoding = AudioFormat.ENCODING_PCM_16BIT
|
|
30
|
+
private val bytesPerFrame = config.channels * 2
|
|
31
|
+
private val bytesPerSecond = config.sampleRate * bytesPerFrame
|
|
32
|
+
|
|
33
|
+
private val minBufferBytes by lazy {
|
|
34
|
+
val min = AudioRecord.getMinBufferSize(config.sampleRate, channelMask, encoding)
|
|
35
|
+
if (min <= 0) throw RecorderError("UNSUPPORTED_FORMAT", "AudioRecord rejected format")
|
|
36
|
+
val target = max(min, bytesPerSecond / RecorderConstants.CAPTURE_BUFFER_FRACTION_OF_SECOND)
|
|
37
|
+
target + (target % bytesPerFrame)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private var audioRecord: AudioRecord? = null
|
|
41
|
+
private var captureThread: Thread? = null
|
|
42
|
+
private var encodeThread: Thread? = null
|
|
43
|
+
|
|
44
|
+
private val running = AtomicBoolean(false)
|
|
45
|
+
private val paused = AtomicBoolean(false)
|
|
46
|
+
|
|
47
|
+
private val pcmQueue = LinkedBlockingQueue<PcmChunk>(RecorderConstants.PCM_QUEUE_CAPACITY)
|
|
48
|
+
|
|
49
|
+
@Volatile private var sessionStartMs = 0L
|
|
50
|
+
@Volatile private var currentFilePath: String? = null
|
|
51
|
+
@Volatile private var totalBytesWritten = 0L
|
|
52
|
+
@Volatile private var lastLevelEmitMs = 0L
|
|
53
|
+
|
|
54
|
+
private data class PcmChunk(val bytes: ByteArray, val isEos: Boolean)
|
|
55
|
+
|
|
56
|
+
fun prepare() {
|
|
57
|
+
requirePermission()
|
|
58
|
+
audioRecord = AudioRecord.Builder()
|
|
59
|
+
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
60
|
+
.setAudioFormat(
|
|
61
|
+
AudioFormat.Builder()
|
|
62
|
+
.setSampleRate(config.sampleRate)
|
|
63
|
+
.setChannelMask(channelMask)
|
|
64
|
+
.setEncoding(encoding)
|
|
65
|
+
.build(),
|
|
66
|
+
)
|
|
67
|
+
.setBufferSizeInBytes(minBufferBytes * 2)
|
|
68
|
+
.build()
|
|
69
|
+
.also {
|
|
70
|
+
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
|
71
|
+
it.release()
|
|
72
|
+
throw RecorderError("ENGINE_NOT_READY", "AudioRecord failed to initialize")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fun start() {
|
|
78
|
+
val record = audioRecord ?: throw RecorderError("ENGINE_NOT_READY", "Call prepare() first")
|
|
79
|
+
sessionStartMs = System.currentTimeMillis()
|
|
80
|
+
totalBytesWritten = 0L
|
|
81
|
+
pcmQueue.clear()
|
|
82
|
+
|
|
83
|
+
record.startRecording()
|
|
84
|
+
running.set(true)
|
|
85
|
+
paused.set(false)
|
|
86
|
+
|
|
87
|
+
captureThread = Thread(::captureLoop, "Clarion-Capture").apply { start() }
|
|
88
|
+
encodeThread = Thread(::encodeLoop, "Clarion-Encode").apply { start() }
|
|
89
|
+
|
|
90
|
+
callbacks.onState("recording")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fun pause() {
|
|
94
|
+
if (!running.get()) throw RecorderError("INVALID_STATE", "Not recording")
|
|
95
|
+
paused.set(true)
|
|
96
|
+
callbacks.onState("paused")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun resume() {
|
|
100
|
+
if (!running.get()) throw RecorderError("INVALID_STATE", "Not recording")
|
|
101
|
+
paused.set(false)
|
|
102
|
+
callbacks.onState("recording")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fun stop(): RecorderResult {
|
|
106
|
+
if (!running.get()) throw RecorderError("INVALID_STATE", "Not recording")
|
|
107
|
+
callbacks.onState("stopping")
|
|
108
|
+
|
|
109
|
+
running.set(false)
|
|
110
|
+
pcmQueue.offer(PcmChunk(ByteArray(0), isEos = true))
|
|
111
|
+
|
|
112
|
+
encodeThread?.join(RecorderConstants.ENCODE_THREAD_JOIN_TIMEOUT_MS)
|
|
113
|
+
captureThread?.join(RecorderConstants.CAPTURE_THREAD_JOIN_TIMEOUT_MS)
|
|
114
|
+
|
|
115
|
+
runCatching { audioRecord?.stop() }
|
|
116
|
+
audioRecord?.release()
|
|
117
|
+
audioRecord = null
|
|
118
|
+
|
|
119
|
+
val finalUri = currentFilePath?.let { "${RecorderConstants.URI_SCHEME}$it" }
|
|
120
|
+
?: throw RecorderError("INTERNAL_ERROR", "No output file produced")
|
|
121
|
+
|
|
122
|
+
val durationMs = System.currentTimeMillis() - sessionStartMs
|
|
123
|
+
callbacks.onState("idle")
|
|
124
|
+
|
|
125
|
+
return RecorderResult(
|
|
126
|
+
uri = finalUri,
|
|
127
|
+
durationMs = durationMs,
|
|
128
|
+
sizeBytes = totalBytesWritten,
|
|
129
|
+
sampleRate = config.sampleRate,
|
|
130
|
+
channels = config.channels,
|
|
131
|
+
bitDepth = config.bitDepth,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fun discard() {
|
|
136
|
+
val wasRecording = running.getAndSet(false)
|
|
137
|
+
pcmQueue.offer(PcmChunk(ByteArray(0), isEos = true))
|
|
138
|
+
encodeThread?.join(RecorderConstants.CANCEL_ENCODE_JOIN_TIMEOUT_MS)
|
|
139
|
+
captureThread?.join(RecorderConstants.CANCEL_CAPTURE_JOIN_TIMEOUT_MS)
|
|
140
|
+
runCatching { audioRecord?.stop() }
|
|
141
|
+
audioRecord?.release()
|
|
142
|
+
audioRecord = null
|
|
143
|
+
|
|
144
|
+
if (wasRecording) {
|
|
145
|
+
// Only delete in-progress recordings; never delete a finalized file.
|
|
146
|
+
currentFilePath?.let { runCatching { File(it).delete() } }
|
|
147
|
+
}
|
|
148
|
+
currentFilePath = null
|
|
149
|
+
callbacks.onState("idle")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fun release() {
|
|
153
|
+
if (running.get()) discard()
|
|
154
|
+
audioRecord?.release()
|
|
155
|
+
audioRecord = null
|
|
156
|
+
callbacks.onState("released")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fun captureLoop() {
|
|
160
|
+
val buffer = ByteArray(minBufferBytes)
|
|
161
|
+
callbacks.runCatchingRecorder("IO_ERROR", "Capture failed") {
|
|
162
|
+
while (running.get()) {
|
|
163
|
+
val read = audioRecord?.read(buffer, 0, buffer.size) ?: -1
|
|
164
|
+
when {
|
|
165
|
+
read > 0 -> {
|
|
166
|
+
if (!paused.get()) {
|
|
167
|
+
if (config.emitAudioLevel) maybeEmitLevels(buffer, read)
|
|
168
|
+
pcmQueue.put(PcmChunk(buffer.copyOf(read), isEos = false))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
read == AudioRecord.ERROR_INVALID_OPERATION ||
|
|
172
|
+
read == AudioRecord.ERROR_BAD_VALUE ||
|
|
173
|
+
read == AudioRecord.ERROR_DEAD_OBJECT ||
|
|
174
|
+
read == AudioRecord.ERROR -> {
|
|
175
|
+
throw RecorderError("IO_ERROR", "AudioRecord.read() returned $read")
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun encodeLoop() {
|
|
183
|
+
var session: EncodeFile? = null
|
|
184
|
+
var presentationUs = 0L
|
|
185
|
+
var fileStartedAtMs = System.currentTimeMillis()
|
|
186
|
+
|
|
187
|
+
callbacks.runCatchingRecorder("INTERNAL_ERROR", "Encode failed") {
|
|
188
|
+
session = openEncodeFile().also { currentFilePath = it.path }
|
|
189
|
+
val rotateMs = config.rotateAfterMs
|
|
190
|
+
|
|
191
|
+
while (true) {
|
|
192
|
+
val chunk = pcmQueue.poll(
|
|
193
|
+
RecorderConstants.PCM_QUEUE_POLL_TIMEOUT_MS,
|
|
194
|
+
TimeUnit.MILLISECONDS,
|
|
195
|
+
) ?: continue
|
|
196
|
+
|
|
197
|
+
val activeSession = session ?: break
|
|
198
|
+
val rotateNow = !chunk.isEos && rotateMs != null &&
|
|
199
|
+
(System.currentTimeMillis() - fileStartedAtMs) >= rotateMs
|
|
200
|
+
|
|
201
|
+
if (chunk.isEos || rotateNow) {
|
|
202
|
+
drainAndFinalize(activeSession, presentationUs, isEos = true)
|
|
203
|
+
emitChunkComplete(activeSession, fileStartedAtMs)
|
|
204
|
+
activeSession.close()
|
|
205
|
+
|
|
206
|
+
if (chunk.isEos) {
|
|
207
|
+
currentFilePath = activeSession.path
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
presentationUs = 0L
|
|
212
|
+
fileStartedAtMs = System.currentTimeMillis()
|
|
213
|
+
session = openEncodeFile().also { currentFilePath = it.path }
|
|
214
|
+
if (chunk.bytes.isNotEmpty()) {
|
|
215
|
+
presentationUs = feedPcm(session!!, chunk.bytes, presentationUs)
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
presentationUs = feedPcm(activeSession, chunk.bytes, presentationUs)
|
|
219
|
+
drainEncoder(activeSession, endOfStream = false)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
session?.runCatching { close() }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private fun feedPcm(session: EncodeFile, pcm: ByteArray, basePtsUs: Long): Long {
|
|
227
|
+
val inIndex = session.codec.dequeueInputBuffer(RecorderConstants.CODEC_DEQUEUE_TIMEOUT_US)
|
|
228
|
+
if (inIndex < 0) return basePtsUs
|
|
229
|
+
|
|
230
|
+
val inBuf: ByteBuffer = session.codec.getInputBuffer(inIndex)
|
|
231
|
+
?: throw RecorderError("INTERNAL_ERROR", "Null input buffer")
|
|
232
|
+
inBuf.clear()
|
|
233
|
+
inBuf.put(pcm)
|
|
234
|
+
session.codec.queueInputBuffer(inIndex, 0, pcm.size, basePtsUs, 0)
|
|
235
|
+
|
|
236
|
+
return basePtsUs + (pcm.size.toLong() * 1_000_000L) / bytesPerSecond
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private fun drainAndFinalize(session: EncodeFile, ptsUs: Long, isEos: Boolean) {
|
|
240
|
+
if (isEos) {
|
|
241
|
+
val inIndex = session.codec.dequeueInputBuffer(RecorderConstants.CODEC_DEQUEUE_TIMEOUT_US)
|
|
242
|
+
if (inIndex >= 0) {
|
|
243
|
+
session.codec.queueInputBuffer(
|
|
244
|
+
inIndex, 0, 0, ptsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
drainEncoder(session, endOfStream = isEos)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun drainEncoder(session: EncodeFile, endOfStream: Boolean) {
|
|
252
|
+
val info = MediaCodec.BufferInfo()
|
|
253
|
+
val dequeueTimeoutUs = if (endOfStream) RecorderConstants.CODEC_DEQUEUE_TIMEOUT_US else 0L
|
|
254
|
+
while (true) {
|
|
255
|
+
val outIndex = session.codec.dequeueOutputBuffer(info, dequeueTimeoutUs)
|
|
256
|
+
when {
|
|
257
|
+
outIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!endOfStream) return
|
|
258
|
+
outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> startMuxer(session)
|
|
259
|
+
outIndex >= 0 -> {
|
|
260
|
+
if (writeOutputBuffer(session, outIndex, info)) return
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun startMuxer(session: EncodeFile) {
|
|
267
|
+
if (session.muxerTrackIndex >= 0) {
|
|
268
|
+
throw RecorderError("INTERNAL_ERROR", "Format changed after muxer start")
|
|
269
|
+
}
|
|
270
|
+
session.muxerTrackIndex = session.muxer.addTrack(session.codec.outputFormat)
|
|
271
|
+
session.muxer.start()
|
|
272
|
+
session.muxerStarted = true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private fun writeOutputBuffer(
|
|
276
|
+
session: EncodeFile,
|
|
277
|
+
outIndex: Int,
|
|
278
|
+
info: MediaCodec.BufferInfo,
|
|
279
|
+
): Boolean {
|
|
280
|
+
val outBuf: ByteBuffer = session.codec.getOutputBuffer(outIndex)
|
|
281
|
+
?: throw RecorderError("INTERNAL_ERROR", "Null output buffer")
|
|
282
|
+
|
|
283
|
+
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) info.size = 0
|
|
284
|
+
|
|
285
|
+
if (info.size > 0 && session.muxerStarted) {
|
|
286
|
+
outBuf.position(info.offset)
|
|
287
|
+
outBuf.limit(info.offset + info.size)
|
|
288
|
+
session.muxer.writeSampleData(session.muxerTrackIndex, outBuf, info)
|
|
289
|
+
session.bytesWritten += info.size
|
|
290
|
+
totalBytesWritten += info.size
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
session.codec.releaseOutputBuffer(outIndex, false)
|
|
294
|
+
return info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private fun emitChunkComplete(session: EncodeFile, fileStartedAtMs: Long) {
|
|
298
|
+
callbacks.onChunk(
|
|
299
|
+
uri = "${RecorderConstants.URI_SCHEME}${session.path}",
|
|
300
|
+
startMs = fileStartedAtMs,
|
|
301
|
+
endMs = System.currentTimeMillis(),
|
|
302
|
+
sizeBytes = session.bytesWritten,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun openEncodeFile(): EncodeFile {
|
|
307
|
+
val baseDir = config.outputDirectory?.let { File(it) }
|
|
308
|
+
?: File(context.cacheDir, RecorderConstants.DEFAULT_CACHE_SUBDIR)
|
|
309
|
+
return EncodeFile.open(
|
|
310
|
+
outputDir = baseDir,
|
|
311
|
+
filenamePrefix = config.filenamePrefix ?: RecorderConstants.DEFAULT_FILENAME_PREFIX,
|
|
312
|
+
sampleRate = config.sampleRate,
|
|
313
|
+
channels = config.channels,
|
|
314
|
+
aacBitrate = config.aacBitrate,
|
|
315
|
+
maxInputSize = minBufferBytes * 2,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private fun maybeEmitLevels(buffer: ByteArray, length: Int) {
|
|
320
|
+
val now = System.currentTimeMillis()
|
|
321
|
+
if (now - lastLevelEmitMs < config.audioLevelIntervalMs) return
|
|
322
|
+
lastLevelEmitMs = now
|
|
323
|
+
val levels = AudioLevelMeter.compute(buffer, length)
|
|
324
|
+
callbacks.onAudioLevel(levels.rms, levels.peak)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private fun requirePermission() {
|
|
328
|
+
val granted = ContextCompat.checkSelfPermission(
|
|
329
|
+
context,
|
|
330
|
+
Manifest.permission.RECORD_AUDIO,
|
|
331
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
332
|
+
if (!granted) {
|
|
333
|
+
throw RecorderError("PERMISSION_DENIED", "RECORD_AUDIO permission not granted")
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package com.clarionhq.recorder
|
|
2
|
+
|
|
3
|
+
internal class RecorderError(
|
|
4
|
+
val code: String,
|
|
5
|
+
message: String,
|
|
6
|
+
val recoverable: Boolean = false,
|
|
7
|
+
cause: Throwable? = null,
|
|
8
|
+
) : Exception(message, cause)
|
|
9
|
+
|
|
10
|
+
internal interface RecorderCallbacks {
|
|
11
|
+
fun onState(state: String)
|
|
12
|
+
fun onAudioLevel(rms: Double, peak: Double)
|
|
13
|
+
fun onChunk(uri: String, startMs: Long, endMs: Long, sizeBytes: Long)
|
|
14
|
+
fun onError(error: RecorderError)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
internal inline fun RecorderCallbacks.runCatchingRecorder(
|
|
18
|
+
fallbackCode: String,
|
|
19
|
+
fallbackPrefix: String,
|
|
20
|
+
block: () -> Unit,
|
|
21
|
+
) {
|
|
22
|
+
try {
|
|
23
|
+
block()
|
|
24
|
+
} catch (e: RecorderError) {
|
|
25
|
+
onError(e)
|
|
26
|
+
} catch (e: Throwable) {
|
|
27
|
+
onError(RecorderError(fallbackCode, "$fallbackPrefix: ${e.message}", cause = e))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
package com.margelo.nitro.clarion.recorder
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.clarionhq.recorder.RecorderCallbacks
|
|
5
|
+
import com.clarionhq.recorder.RecorderConfig
|
|
6
|
+
import com.clarionhq.recorder.RecorderError
|
|
7
|
+
import com.clarionhq.recorder.RecorderResult
|
|
8
|
+
import com.clarionhq.recorder.RecorderSession
|
|
9
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
10
|
+
import com.margelo.nitro.NitroModules
|
|
11
|
+
import com.margelo.nitro.core.Promise
|
|
12
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
13
|
+
|
|
14
|
+
@DoNotStrip
|
|
15
|
+
class HybridClarionRecorder : HybridClarionRecorderSpec() {
|
|
16
|
+
override val memorySize: Long get() = 0L
|
|
17
|
+
|
|
18
|
+
private val context: Context = NitroModules.applicationContext
|
|
19
|
+
?: error("Nitro applicationContext is null — was the module initialized?")
|
|
20
|
+
|
|
21
|
+
private val nextListenerId = AtomicInteger(1)
|
|
22
|
+
private val stateListeners = mutableMapOf<Int, (String) -> Unit>()
|
|
23
|
+
private val audioLevelListeners = mutableMapOf<Int, (Double, Double) -> Unit>()
|
|
24
|
+
private val chunkListeners = mutableMapOf<Int, (String, Double, Double, Double) -> Unit>()
|
|
25
|
+
private val errorListeners = mutableMapOf<Int, (NativeRecorderError) -> Unit>()
|
|
26
|
+
|
|
27
|
+
@Volatile private var session: RecorderSession? = null
|
|
28
|
+
@Volatile private var currentState: String = "idle"
|
|
29
|
+
|
|
30
|
+
private val callbacks = object : RecorderCallbacks {
|
|
31
|
+
override fun onState(state: String) {
|
|
32
|
+
currentState = state
|
|
33
|
+
stateListeners.values.toList().forEach { runCatching { it(state) } }
|
|
34
|
+
}
|
|
35
|
+
override fun onAudioLevel(rms: Double, peak: Double) {
|
|
36
|
+
audioLevelListeners.values.toList().forEach { runCatching { it(rms, peak) } }
|
|
37
|
+
}
|
|
38
|
+
override fun onChunk(uri: String, startMs: Long, endMs: Long, sizeBytes: Long) {
|
|
39
|
+
chunkListeners.values.toList().forEach {
|
|
40
|
+
runCatching { it(uri, startMs.toDouble(), endMs.toDouble(), sizeBytes.toDouble()) }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
override fun onError(error: RecorderError) {
|
|
44
|
+
currentState = "error"
|
|
45
|
+
val payload = NativeRecorderError(
|
|
46
|
+
code = error.code,
|
|
47
|
+
message = error.message ?: "Unknown error",
|
|
48
|
+
recoverable = error.recoverable,
|
|
49
|
+
)
|
|
50
|
+
errorListeners.values.toList().forEach { runCatching { it(payload) } }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override val state: String get() = currentState
|
|
55
|
+
|
|
56
|
+
override fun prepare(config: NativeRecorderConfig): Promise<Unit> =
|
|
57
|
+
Promise.async {
|
|
58
|
+
runOrThrow {
|
|
59
|
+
val parsed = config.toKotlinConfig()
|
|
60
|
+
val newSession = RecorderSession(context, parsed, callbacks)
|
|
61
|
+
callbacks.onState("preparing")
|
|
62
|
+
newSession.prepare()
|
|
63
|
+
session = newSession
|
|
64
|
+
callbacks.onState("ready")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override fun start(): Promise<Unit> = Promise.async {
|
|
69
|
+
runOrThrow {
|
|
70
|
+
val s = session ?: throw RecorderError("ENGINE_NOT_READY", "Call prepare() first")
|
|
71
|
+
callbacks.onState("starting")
|
|
72
|
+
s.start()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun pause(): Promise<Unit> = Promise.async {
|
|
77
|
+
runOrThrow {
|
|
78
|
+
session?.pause() ?: throw RecorderError("ENGINE_NOT_READY", "No session")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override fun resume(): Promise<Unit> = Promise.async {
|
|
83
|
+
runOrThrow {
|
|
84
|
+
session?.resume() ?: throw RecorderError("ENGINE_NOT_READY", "No session")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override fun stop(): Promise<NativeRecorderResult> = Promise.async {
|
|
89
|
+
runOrThrow {
|
|
90
|
+
val s = session ?: throw RecorderError("ENGINE_NOT_READY", "No session")
|
|
91
|
+
val result = s.stop().toNative()
|
|
92
|
+
session = null
|
|
93
|
+
result
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override fun discard(): Promise<Unit> = Promise.async {
|
|
98
|
+
runOrThrow {
|
|
99
|
+
session?.discard()
|
|
100
|
+
session = null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override fun release(): Promise<Unit> = Promise.async {
|
|
105
|
+
runOrThrow {
|
|
106
|
+
session?.release()
|
|
107
|
+
session = null
|
|
108
|
+
removeAllListeners()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private inline fun <T> runOrThrow(block: () -> T): T {
|
|
113
|
+
return try {
|
|
114
|
+
block()
|
|
115
|
+
} catch (e: RecorderError) {
|
|
116
|
+
throw RuntimeException("[${e.code}] ${e.message}", e)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
override fun addStateListener(listener: (String) -> Unit): Double =
|
|
121
|
+
register(stateListeners, listener)
|
|
122
|
+
|
|
123
|
+
override fun addAudioLevelListener(listener: (Double, Double) -> Unit): Double =
|
|
124
|
+
register(audioLevelListeners, listener)
|
|
125
|
+
|
|
126
|
+
override fun addChunkListener(
|
|
127
|
+
listener: (String, Double, Double, Double) -> Unit,
|
|
128
|
+
): Double = register(chunkListeners, listener)
|
|
129
|
+
|
|
130
|
+
override fun addErrorListener(listener: (NativeRecorderError) -> Unit): Double =
|
|
131
|
+
register(errorListeners, listener)
|
|
132
|
+
|
|
133
|
+
override fun removeListener(id: Double) {
|
|
134
|
+
val key = id.toInt()
|
|
135
|
+
stateListeners.remove(key)
|
|
136
|
+
audioLevelListeners.remove(key)
|
|
137
|
+
chunkListeners.remove(key)
|
|
138
|
+
errorListeners.remove(key)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
override fun removeAllListeners() {
|
|
142
|
+
stateListeners.clear()
|
|
143
|
+
audioLevelListeners.clear()
|
|
144
|
+
chunkListeners.clear()
|
|
145
|
+
errorListeners.clear()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private fun <T> register(map: MutableMap<Int, T>, listener: T): Double {
|
|
149
|
+
val id = nextListenerId.getAndIncrement()
|
|
150
|
+
map[id] = listener
|
|
151
|
+
return id.toDouble()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun NativeRecorderConfig.toKotlinConfig(): RecorderConfig = RecorderConfig(
|
|
156
|
+
sampleRate = sampleRate.toInt(),
|
|
157
|
+
channels = channels.toInt(),
|
|
158
|
+
bitDepth = bitDepth.toInt(),
|
|
159
|
+
outputDirectory = outputDirectory,
|
|
160
|
+
filenamePrefix = filenamePrefix,
|
|
161
|
+
rotateAfterMs = rotateAfterMs?.toLong(),
|
|
162
|
+
emitAudioLevel = emitAudioLevel,
|
|
163
|
+
audioLevelIntervalMs = audioLevelIntervalMs.toInt(),
|
|
164
|
+
aacBitrate = aacBitrate.toInt(),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
private fun RecorderResult.toNative(): NativeRecorderResult = NativeRecorderResult(
|
|
168
|
+
uri = uri,
|
|
169
|
+
durationMs = durationMs.toDouble(),
|
|
170
|
+
sizeBytes = sizeBytes.toDouble(),
|
|
171
|
+
sampleRate = sampleRate.toDouble(),
|
|
172
|
+
channels = channels.toDouble(),
|
|
173
|
+
bitDepth = bitDepth.toDouble(),
|
|
174
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "clarionhq-recorder"
|
|
7
|
+
s.module_name = "ClarionRecorder"
|
|
8
|
+
s.version = package["version"]
|
|
9
|
+
s.summary = package["description"]
|
|
10
|
+
s.homepage = "https://github.com/Th4nderG0d/clarion"
|
|
11
|
+
s.license = package["license"]
|
|
12
|
+
s.author = { "Clarion" => "hello@clarionhq.dev" }
|
|
13
|
+
s.platforms = { :ios => "15.1" }
|
|
14
|
+
s.source = { :git => "https://github.com/Th4nderG0d/clarion.git", :tag => "v#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{swift,h,m,mm,cpp}"
|
|
17
|
+
s.exclude_files = "ios/**/*.swift.bak"
|
|
18
|
+
|
|
19
|
+
s.frameworks = "AVFoundation", "AudioToolbox", "CoreMedia"
|
|
20
|
+
|
|
21
|
+
s.pod_target_xcconfig = {
|
|
22
|
+
"DEFINES_MODULE" => "YES",
|
|
23
|
+
"SWIFT_VERSION" => "5.9",
|
|
24
|
+
"OTHER_SWIFT_FLAGS" => "$(inherited) -DCLARION_RECORDER"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
load "nitrogen/generated/ios/ClarionRecorder+autolinking.rb"
|
|
28
|
+
add_nitrogen_files(s)
|
|
29
|
+
|
|
30
|
+
s.dependency "React-Core"
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
internal struct AudioLevels {
|
|
5
|
+
let rms: Double
|
|
6
|
+
let peak: Double
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
internal enum AudioLevelMeter {
|
|
10
|
+
static func compute(buffer: AVAudioPCMBuffer) -> AudioLevels {
|
|
11
|
+
guard let floatData = buffer.floatChannelData else {
|
|
12
|
+
return AudioLevels(rms: 0, peak: 0)
|
|
13
|
+
}
|
|
14
|
+
let channelCount = Int(buffer.format.channelCount)
|
|
15
|
+
let frameCount = Int(buffer.frameLength)
|
|
16
|
+
if frameCount == 0 || channelCount == 0 {
|
|
17
|
+
return AudioLevels(rms: 0, peak: 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var sumSquares: Double = 0
|
|
21
|
+
var peakAbs: Float = 0
|
|
22
|
+
|
|
23
|
+
for channel in 0..<channelCount {
|
|
24
|
+
let samples = floatData[channel]
|
|
25
|
+
for frame in 0..<frameCount {
|
|
26
|
+
let sample = samples[frame]
|
|
27
|
+
let absSample = abs(sample)
|
|
28
|
+
if absSample > peakAbs { peakAbs = absSample }
|
|
29
|
+
sumSquares += Double(sample) * Double(sample)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let totalSamples = Double(frameCount * channelCount)
|
|
34
|
+
let rms = (sumSquares > 0) ? (sumSquares / totalSamples).squareRoot() : 0
|
|
35
|
+
return AudioLevels(rms: min(rms, 1.0), peak: min(Double(peakAbs), 1.0))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Wraps a single AVAudioFile write target. Simpler than driving AVAssetWriter
|
|
5
|
+
/// directly — the file negotiates AAC encoding internally and accepts
|
|
6
|
+
/// AVAudioPCMBuffer in our chosen PCM format without any CMSampleBuffer dance.
|
|
7
|
+
internal final class EncodeFile {
|
|
8
|
+
let url: URL
|
|
9
|
+
private var audioFile: AVAudioFile?
|
|
10
|
+
private(set) var bytesWritten: Int64 = 0
|
|
11
|
+
private(set) var finished: Bool = false
|
|
12
|
+
|
|
13
|
+
private init(url: URL) {
|
|
14
|
+
self.url = url
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static func open(
|
|
18
|
+
in directory: URL,
|
|
19
|
+
filenamePrefix: String,
|
|
20
|
+
sampleRate: Double,
|
|
21
|
+
channels: UInt32,
|
|
22
|
+
aacBitrate: Int
|
|
23
|
+
) throws -> EncodeFile {
|
|
24
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
25
|
+
let filename = "\(filenamePrefix)-\(UUID().uuidString).\(RecorderConstants.outputExtension)"
|
|
26
|
+
let url = directory.appendingPathComponent(filename)
|
|
27
|
+
|
|
28
|
+
// Don't pass AVEncoderBitRateKey — iOS rejects bitrates that the encoder's
|
|
29
|
+
// tables don't list as valid for the (sampleRate, channels) combo with
|
|
30
|
+
// kAudioFormatUnsupportedDataFormatError. Letting the encoder pick a
|
|
31
|
+
// sensible default for the format is the robust path.
|
|
32
|
+
var outputSettings: [String: Any] = [
|
|
33
|
+
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
34
|
+
AVSampleRateKey: sampleRate,
|
|
35
|
+
AVNumberOfChannelsKey: channels,
|
|
36
|
+
]
|
|
37
|
+
// 32 kbps is the safe AAC-LC bitrate for 16 kHz mono. Apply only when
|
|
38
|
+
// explicitly requested via a sensible value; otherwise rely on the default.
|
|
39
|
+
if aacBitrate > 0 && aacBitrate <= 48_000 {
|
|
40
|
+
outputSettings[AVEncoderBitRateKey] = aacBitrate
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let file = EncodeFile(url: url)
|
|
44
|
+
file.audioFile = try AVAudioFile(
|
|
45
|
+
forWriting: url,
|
|
46
|
+
settings: outputSettings,
|
|
47
|
+
commonFormat: .pcmFormatInt16,
|
|
48
|
+
interleaved: true
|
|
49
|
+
)
|
|
50
|
+
return file
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func write(buffer: AVAudioPCMBuffer) throws {
|
|
54
|
+
guard !finished, let audioFile else { return }
|
|
55
|
+
try audioFile.write(from: buffer)
|
|
56
|
+
let bytesPerFrame = Int64(buffer.format.streamDescription.pointee.mBytesPerFrame)
|
|
57
|
+
bytesWritten += Int64(buffer.frameLength) * bytesPerFrame
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func close() {
|
|
61
|
+
finished = true
|
|
62
|
+
audioFile = nil
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func cancel() {
|
|
66
|
+
close()
|
|
67
|
+
try? FileManager.default.removeItem(at: url)
|
|
68
|
+
}
|
|
69
|
+
}
|