@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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/android/build.gradle +97 -0
  4. package/android/gradle.properties +4 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/cpp/cpp-adapter.cpp +7 -0
  7. package/android/src/main/java/com/clarionhq/recorder/AudioLevelMeter.kt +38 -0
  8. package/android/src/main/java/com/clarionhq/recorder/ClarionRecorderPackage.kt +27 -0
  9. package/android/src/main/java/com/clarionhq/recorder/EncodeFile.kt +62 -0
  10. package/android/src/main/java/com/clarionhq/recorder/RecorderConfig.kt +33 -0
  11. package/android/src/main/java/com/clarionhq/recorder/RecorderConstants.kt +16 -0
  12. package/android/src/main/java/com/clarionhq/recorder/RecorderSession.kt +336 -0
  13. package/android/src/main/java/com/clarionhq/recorder/RecorderTypes.kt +29 -0
  14. package/android/src/main/java/com/margelo/nitro/clarion/recorder/HybridClarionRecorder.kt +174 -0
  15. package/clarionhq-recorder.podspec +31 -0
  16. package/ios/AudioLevelMeter.swift +37 -0
  17. package/ios/EncodeFile.swift +69 -0
  18. package/ios/HybridClarionRecorder.swift +186 -0
  19. package/ios/RecorderConstants.swift +11 -0
  20. package/ios/RecorderSession.swift +278 -0
  21. package/ios/RecorderTypes.swift +41 -0
  22. package/lib/RecorderEngine.d.ts +31 -0
  23. package/lib/RecorderEngine.d.ts.map +1 -0
  24. package/lib/RecorderEngine.js +245 -0
  25. package/lib/RecorderEngine.js.map +1 -0
  26. package/lib/index.d.ts +4 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +2 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/native.d.ts +3 -0
  31. package/lib/native.d.ts.map +1 -0
  32. package/lib/native.js +3 -0
  33. package/lib/native.js.map +1 -0
  34. package/lib/specs/ClarionRecorder.nitro.d.ts +49 -0
  35. package/lib/specs/ClarionRecorder.nitro.d.ts.map +1 -0
  36. package/lib/specs/ClarionRecorder.nitro.js +2 -0
  37. package/lib/specs/ClarionRecorder.nitro.js.map +1 -0
  38. package/nitro.json +24 -0
  39. package/nitrogen/generated/android/ClarionRecorder+autolinking.cmake +81 -0
  40. package/nitrogen/generated/android/ClarionRecorder+autolinking.gradle +27 -0
  41. package/nitrogen/generated/android/ClarionRecorderOnLoad.cpp +62 -0
  42. package/nitrogen/generated/android/ClarionRecorderOnLoad.hpp +34 -0
  43. package/nitrogen/generated/android/c++/JFunc_void_NativeRecorderError.hpp +78 -0
  44. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  45. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  46. package/nitrogen/generated/android/c++/JFunc_void_std__string_double_double_double.hpp +76 -0
  47. package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.cpp +207 -0
  48. package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.hpp +75 -0
  49. package/nitrogen/generated/android/c++/JNativeRecorderConfig.hpp +90 -0
  50. package/nitrogen/generated/android/c++/JNativeRecorderError.hpp +65 -0
  51. package/nitrogen/generated/android/c++/JNativeRecorderResult.hpp +77 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/ClarionRecorderOnLoad.kt +35 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_NativeRecorderError.kt +80 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_double_double.kt +80 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string.kt +80 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string_double_double_double.kt +80 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/HybridClarionRecorderSpec.kt +125 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderConfig.kt +91 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderError.kt +61 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderResult.kt +76 -0
  61. package/nitrogen/generated/ios/ClarionRecorder+autolinking.rb +62 -0
  62. package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.cpp +89 -0
  63. package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.hpp +297 -0
  64. package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Umbrella.hpp +56 -0
  65. package/nitrogen/generated/ios/ClarionRecorderAutolinking.mm +33 -0
  66. package/nitrogen/generated/ios/ClarionRecorderAutolinking.swift +26 -0
  67. package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.cpp +11 -0
  68. package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.hpp +188 -0
  69. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  70. package/nitrogen/generated/ios/swift/Func_void_NativeRecorderError.swift +46 -0
  71. package/nitrogen/generated/ios/swift/Func_void_NativeRecorderResult.swift +46 -0
  72. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  73. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  74. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  75. package/nitrogen/generated/ios/swift/Func_void_std__string_double_double_double.swift +46 -0
  76. package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec.swift +67 -0
  77. package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec_cxx.swift +354 -0
  78. package/nitrogen/generated/ios/swift/NativeRecorderConfig.swift +108 -0
  79. package/nitrogen/generated/ios/swift/NativeRecorderError.swift +39 -0
  80. package/nitrogen/generated/ios/swift/NativeRecorderResult.swift +54 -0
  81. package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.cpp +34 -0
  82. package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.hpp +84 -0
  83. package/nitrogen/generated/shared/c++/NativeRecorderConfig.hpp +116 -0
  84. package/nitrogen/generated/shared/c++/NativeRecorderError.hpp +91 -0
  85. package/nitrogen/generated/shared/c++/NativeRecorderResult.hpp +103 -0
  86. package/package.json +66 -8
  87. package/src/RecorderEngine.ts +298 -0
  88. package/src/index.ts +8 -0
  89. package/src/native.ts +5 -0
  90. package/src/specs/ClarionRecorder.nitro.ts +58 -0
  91. 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
+ }