@edkimmel/expo-audio-stream 0.6.4 → 1.0.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.
|
@@ -30,10 +30,20 @@ class AudioRecorderManager(
|
|
|
30
30
|
private var totalRecordedTime: Long = 0
|
|
31
31
|
private var totalDataSize = 0
|
|
32
32
|
private var pausedDuration = 0L
|
|
33
|
-
private var lastEmittedSize = 0L
|
|
34
33
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
35
34
|
private val audioRecordLock = Any()
|
|
36
35
|
|
|
36
|
+
// ── Capture/processing decoupling ───────────────────────────────────────
|
|
37
|
+
// The capture thread does nothing but read() + count + enqueue, so it can
|
|
38
|
+
// always drain the HAL ring buffer at full speed (no DSP/encode/bridge work
|
|
39
|
+
// in its path). A separate processing thread encodes, analyzes, and posts to
|
|
40
|
+
// JS. If processing lags, only *emission* is delayed — captured audio is
|
|
41
|
+
// already counted in totalDataSize and safe in the queue, so no samples are
|
|
42
|
+
// lost and PCM drift stays flat under stress.
|
|
43
|
+
private class CapturedChunk(val bytes: ByteArray, val length: Int, val fromOffset: Long)
|
|
44
|
+
private var processingThread: Thread? = null
|
|
45
|
+
private val chunkQueue = java.util.concurrent.LinkedBlockingQueue<CapturedChunk>()
|
|
46
|
+
|
|
37
47
|
// Flag to control whether actual audio data or silence is sent
|
|
38
48
|
private var isSilent = false
|
|
39
49
|
@Volatile private var micGain: Float = 1.0f
|
|
@@ -44,6 +54,13 @@ class AudioRecorderManager(
|
|
|
44
54
|
private var mimeType = "audio/wav"
|
|
45
55
|
private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT
|
|
46
56
|
|
|
57
|
+
private companion object {
|
|
58
|
+
/** Ring-buffer slack as a multiple of one read interval (and of minBuf).
|
|
59
|
+
* ~4 intervals ≈ 400ms of headroom to absorb scheduling stalls without
|
|
60
|
+
* overrunning the HAL buffer and dropping samples. */
|
|
61
|
+
const val RING_BUFFER_INTERVALS = 4
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
/**
|
|
48
65
|
* Validates the recording state by checking permission and recording status
|
|
49
66
|
* @param promise Promise to reject if validation fails
|
|
@@ -135,11 +152,16 @@ class AudioRecorderManager(
|
|
|
135
152
|
readSizeInBytes = intervalBytes
|
|
136
153
|
|
|
137
154
|
// AudioRecord's internal ring buffer must be >= getMinBufferSize.
|
|
138
|
-
//
|
|
155
|
+
// Size it for several reads of slack — not just one — so a scheduling
|
|
156
|
+
// stall (GC pause, pipeline-connect hitch, the MAX_PRIORITY playback
|
|
157
|
+
// writer preempting us) doesn't overrun the HAL ring buffer and drop
|
|
158
|
+
// captured samples. Dropped samples are permanent loss and show up as
|
|
159
|
+
// accumulating PCM drift. The extra headroom adds no latency: we still
|
|
160
|
+
// read exactly one interval per read() call.
|
|
139
161
|
val channelConfig = if (recordingConfig.channels == 1) AudioFormat.CHANNEL_IN_MONO
|
|
140
162
|
else AudioFormat.CHANNEL_IN_STEREO
|
|
141
163
|
val minBuf = AudioRecord.getMinBufferSize(recordingConfig.sampleRate, channelConfig, audioFormat)
|
|
142
|
-
bufferSizeInBytes = maxOf(intervalBytes, minBuf)
|
|
164
|
+
bufferSizeInBytes = maxOf(intervalBytes * RING_BUFFER_INTERVALS, minBuf * RING_BUFFER_INTERVALS)
|
|
143
165
|
Log.d(Constants.TAG, "Interval: ${recordingConfig.interval}ms, readSize: $readSizeInBytes, ringBuffer: $bufferSizeInBytes (minBuf=$minBuf)")
|
|
144
166
|
|
|
145
167
|
// Initialize the AudioRecord if it's a new recording or if it's not currently paused
|
|
@@ -168,9 +190,8 @@ class AudioRecorderManager(
|
|
|
168
190
|
System.currentTimeMillis() // Only reset start time if it's not a resume
|
|
169
191
|
}
|
|
170
192
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Create frequency band analyzer
|
|
193
|
+
// Create frequency band analyzer before starting the processing thread
|
|
194
|
+
// (the worker consumes it) to avoid a startup race.
|
|
174
195
|
val bandConfig = options["frequencyBandConfig"] as? Map<*, *>
|
|
175
196
|
frequencyBandAnalyzer = FrequencyBandAnalyzer(
|
|
176
197
|
sampleRate = recordingConfig.sampleRate,
|
|
@@ -178,6 +199,14 @@ class AudioRecorderManager(
|
|
|
178
199
|
highCrossoverHz = (bandConfig?.get("highCrossoverHz") as? Number)?.toFloat() ?: 2000f
|
|
179
200
|
)
|
|
180
201
|
|
|
202
|
+
// Discard any chunks left over from a previous (aborted) session.
|
|
203
|
+
chunkQueue.clear()
|
|
204
|
+
|
|
205
|
+
// Capture thread only reads + enqueues; processing thread does the heavy
|
|
206
|
+
// encode/analyze/emit work so the capture path can never be throttled.
|
|
207
|
+
processingThread = Thread { processingLoop() }.apply { start() }
|
|
208
|
+
recordingThread = Thread { recordingProcess() }.apply { start() }
|
|
209
|
+
|
|
181
210
|
val result = bundleOf(
|
|
182
211
|
"fileUri" to "",
|
|
183
212
|
"channels" to recordingConfig.channels,
|
|
@@ -198,10 +227,14 @@ class AudioRecorderManager(
|
|
|
198
227
|
*/
|
|
199
228
|
private fun cleanupResources() {
|
|
200
229
|
try {
|
|
230
|
+
// Signal both loops to stop before tearing anything down.
|
|
231
|
+
isRecording.set(false)
|
|
232
|
+
|
|
201
233
|
// Release audio effects
|
|
202
234
|
audioEffectsManager.releaseAudioEffects()
|
|
203
235
|
|
|
204
|
-
// Stop and release AudioRecord if exists
|
|
236
|
+
// Stop and release AudioRecord if exists. Stopping unblocks the
|
|
237
|
+
// capture thread's read() so it can exit.
|
|
205
238
|
if (audioRecord != null) {
|
|
206
239
|
try {
|
|
207
240
|
if (audioRecord!!.state == AudioRecord.STATE_INITIALIZED) {
|
|
@@ -219,19 +252,21 @@ class AudioRecorderManager(
|
|
|
219
252
|
audioRecord = null
|
|
220
253
|
}
|
|
221
254
|
|
|
222
|
-
// Interrupt
|
|
255
|
+
// Interrupt capture + processing threads (the processing thread may be
|
|
256
|
+
// blocked in chunkQueue.take()); then drop any unprocessed tail chunks.
|
|
223
257
|
recordingThread?.interrupt()
|
|
224
258
|
recordingThread = null
|
|
259
|
+
processingThread?.interrupt()
|
|
260
|
+
processingThread = null
|
|
261
|
+
chunkQueue.clear()
|
|
225
262
|
|
|
226
263
|
// Always reset state
|
|
227
|
-
isRecording.set(false)
|
|
228
264
|
isPaused.set(false)
|
|
229
265
|
totalRecordedTime = 0
|
|
230
266
|
pausedDuration = 0
|
|
231
267
|
totalDataSize = 0
|
|
232
268
|
streamUuid = null
|
|
233
269
|
frequencyBandAnalyzer = null
|
|
234
|
-
lastEmittedSize = 0
|
|
235
270
|
|
|
236
271
|
Log.d(Constants.TAG, "Audio resources cleaned up")
|
|
237
272
|
} catch (e: Exception) {
|
|
@@ -248,12 +283,15 @@ class AudioRecorderManager(
|
|
|
248
283
|
}
|
|
249
284
|
|
|
250
285
|
try {
|
|
251
|
-
// Read any final audio data
|
|
286
|
+
// Read any final audio data and emit it directly (the processing
|
|
287
|
+
// thread is about to be torn down). copyOf so emit owns the bytes.
|
|
252
288
|
val audioData = ByteArray(bufferSizeInBytes)
|
|
253
289
|
val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
|
|
254
290
|
Log.d(Constants.TAG, "Last Read $bytesRead bytes")
|
|
255
291
|
if (bytesRead > 0) {
|
|
256
|
-
|
|
292
|
+
val fromOffset = totalDataSize.toLong()
|
|
293
|
+
totalDataSize += bytesRead
|
|
294
|
+
emitAudioData(audioData.copyOf(bytesRead), bytesRead, fromOffset)
|
|
257
295
|
}
|
|
258
296
|
|
|
259
297
|
// Generate result before cleanup
|
|
@@ -299,6 +337,10 @@ class AudioRecorderManager(
|
|
|
299
337
|
}
|
|
300
338
|
|
|
301
339
|
private fun recordingProcess() {
|
|
340
|
+
// Run the capture loop at urgent-audio priority so it isn't starved by
|
|
341
|
+
// the pipeline's MAX_PRIORITY playback writer (or other load) — starvation
|
|
342
|
+
// here means missed drain windows and dropped capture samples.
|
|
343
|
+
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO)
|
|
302
344
|
Log.i(Constants.TAG, "Starting recording process, readSize=$readSizeInBytes, ringBuffer=$bufferSizeInBytes")
|
|
303
345
|
val audioData = ByteArray(readSizeInBytes)
|
|
304
346
|
var consecutiveErrors = 0
|
|
@@ -333,10 +375,12 @@ class AudioRecorderManager(
|
|
|
333
375
|
|
|
334
376
|
if (bytesRead > 0) {
|
|
335
377
|
consecutiveErrors = 0
|
|
336
|
-
|
|
378
|
+
// Snapshot the pre-chunk offset, advance the counter, and hand
|
|
379
|
+
// a COPY to the processing thread. Counting here (not at emit
|
|
380
|
+
// time) keeps PCM accounting accurate even if emission lags.
|
|
381
|
+
val fromOffset = totalDataSize.toLong()
|
|
337
382
|
totalDataSize += bytesRead
|
|
338
|
-
|
|
339
|
-
emitAudioData(audioData, bytesRead)
|
|
383
|
+
chunkQueue.put(CapturedChunk(audioData.copyOf(bytesRead), bytesRead, fromOffset))
|
|
340
384
|
} else if (bytesRead < 0) {
|
|
341
385
|
consecutiveErrors++
|
|
342
386
|
if (consecutiveErrors >= 10) {
|
|
@@ -346,12 +390,33 @@ class AudioRecorderManager(
|
|
|
346
390
|
}
|
|
347
391
|
}
|
|
348
392
|
}
|
|
393
|
+
} catch (_: InterruptedException) {
|
|
394
|
+
Thread.currentThread().interrupt()
|
|
349
395
|
} catch (e: Exception) {
|
|
350
396
|
Log.e(Constants.TAG, "Recording thread crashed", e)
|
|
351
397
|
emitRecordingError("RECORDING_CRASH", e.message ?: "Recording thread unexpected error", isFatal = true)
|
|
352
398
|
}
|
|
353
399
|
}
|
|
354
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Processing thread: drains captured chunks and does all the heavy work
|
|
403
|
+
* (gain normalization, silence/gain, base64 encode, power level, frequency
|
|
404
|
+
* bands, JS-bridge post). Kept off the capture thread so a slow bridge or DSP
|
|
405
|
+
* spike delays emission instead of stalling capture and dropping samples.
|
|
406
|
+
*/
|
|
407
|
+
private fun processingLoop() {
|
|
408
|
+
try {
|
|
409
|
+
while (isRecording.get() && !Thread.currentThread().isInterrupted) {
|
|
410
|
+
val chunk = chunkQueue.take() // blocks until a chunk is available
|
|
411
|
+
emitAudioData(chunk.bytes, chunk.length, chunk.fromOffset)
|
|
412
|
+
}
|
|
413
|
+
} catch (_: InterruptedException) {
|
|
414
|
+
Thread.currentThread().interrupt()
|
|
415
|
+
} catch (e: Exception) {
|
|
416
|
+
Log.e(Constants.TAG, "Processing thread crashed", e)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
355
420
|
/**
|
|
356
421
|
* Sends a recording error event to JS so the caller can react.
|
|
357
422
|
*/
|
|
@@ -386,7 +451,17 @@ class AudioRecorderManager(
|
|
|
386
451
|
}
|
|
387
452
|
}
|
|
388
453
|
|
|
389
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Runs on the processing thread. [audioData] is this chunk's private copy
|
|
456
|
+
* (safe to mutate) and [fromOffset] is the cumulative byte offset *before*
|
|
457
|
+
* this chunk, snapshotted on the capture thread — so per-chunk position and
|
|
458
|
+
* totalSize stay correct regardless of how far the live counter has advanced.
|
|
459
|
+
*/
|
|
460
|
+
private fun emitAudioData(audioData: ByteArray, length: Int, fromOffset: Long) {
|
|
461
|
+
// Gain normalization was previously done inline on the capture thread;
|
|
462
|
+
// it now runs here so the capture path stays lean.
|
|
463
|
+
gainNormalizer.apply(audioData, length)
|
|
464
|
+
|
|
390
465
|
val dataToEncode = when {
|
|
391
466
|
isSilent -> ByteArray(length)
|
|
392
467
|
micGain != 1.0f -> applyMicGain(audioData, length, micGain)
|
|
@@ -395,9 +470,7 @@ class AudioRecorderManager(
|
|
|
395
470
|
|
|
396
471
|
val encodedBuffer = audioDataEncoder.encodeToBase64(dataToEncode)
|
|
397
472
|
|
|
398
|
-
val from =
|
|
399
|
-
val deltaSize = totalDataSize.toLong() - lastEmittedSize
|
|
400
|
-
lastEmittedSize = totalDataSize.toLong()
|
|
473
|
+
val from = fromOffset
|
|
401
474
|
|
|
402
475
|
// Calculate position in milliseconds
|
|
403
476
|
val positionInMs = (from * 1000) / (recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8)
|
|
@@ -431,7 +504,7 @@ class AudioRecorderManager(
|
|
|
431
504
|
"mid" to (bands?.mid ?: 0f),
|
|
432
505
|
"high" to (bands?.high ?: 0f)
|
|
433
506
|
),
|
|
434
|
-
"totalSize" to
|
|
507
|
+
"totalSize" to (from + length),
|
|
435
508
|
"streamUuid" to streamUuid
|
|
436
509
|
)
|
|
437
510
|
)
|
|
@@ -136,7 +136,23 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
136
136
|
try self.sharedAudioEngine.configure(playbackMode: .conversation)
|
|
137
137
|
}
|
|
138
138
|
} catch {
|
|
139
|
-
|
|
139
|
+
// Surface a typed code so callers can distinguish a contended
|
|
140
|
+
// session (another audio source holding priority) from a genuine
|
|
141
|
+
// config failure. AVAudioSessionErrorInsufficientPriority shows up
|
|
142
|
+
// here as "Session activation failed".
|
|
143
|
+
let nsError = error as NSError
|
|
144
|
+
let code: String
|
|
145
|
+
switch nsError.code {
|
|
146
|
+
case Int(AVAudioSession.ErrorCode.insufficientPriority.rawValue):
|
|
147
|
+
code = "SESSION_INSUFFICIENT_PRIORITY"
|
|
148
|
+
case Int(AVAudioSession.ErrorCode.isBusy.rawValue):
|
|
149
|
+
code = "SESSION_BUSY"
|
|
150
|
+
case Int(AVAudioSession.ErrorCode.cannotInterruptOthers.rawValue):
|
|
151
|
+
code = "SESSION_CANNOT_INTERRUPT_OTHERS"
|
|
152
|
+
default:
|
|
153
|
+
code = "SESSION_INIT_FAILED"
|
|
154
|
+
}
|
|
155
|
+
promise.reject(code, "Failed to init audio session: \(error.localizedDescription) (osstatus=\(nsError.code))")
|
|
140
156
|
return
|
|
141
157
|
}
|
|
142
158
|
|
|
@@ -293,10 +309,40 @@ public class ExpoPlayAudioStreamModule: Module, MicrophoneDataDelegate, Pipeline
|
|
|
293
309
|
let preferredDuration = 512.0 / hwSampleRate
|
|
294
310
|
try audioSession.setPreferredIOBufferDuration(preferredDuration)
|
|
295
311
|
}
|
|
296
|
-
|
|
312
|
+
|
|
313
|
+
// setActive(true) fails with AVAudioSessionErrorInsufficientPriority
|
|
314
|
+
// ("Session activation failed") when another audio source still holds the
|
|
315
|
+
// shared session — e.g. a separate playback library finishing a clip just
|
|
316
|
+
// before recording starts. iOS releases priority a moment later, so a single
|
|
317
|
+
// short-delayed retry recovers the common case. Only the transient
|
|
318
|
+
// insufficient-priority / busy errors are retried; genuine config failures
|
|
319
|
+
// rethrow immediately.
|
|
320
|
+
do {
|
|
321
|
+
try audioSession.setActive(true)
|
|
322
|
+
} catch let error as NSError where ExpoPlayAudioStreamModule.isTransientActivationError(error) {
|
|
323
|
+
Logger.debug("[AudioSession] setActive(true) failed transiently (\(error.code)); retrying once: \(error.localizedDescription)")
|
|
324
|
+
Thread.sleep(forTimeInterval: 0.15)
|
|
325
|
+
try audioSession.setActive(true)
|
|
326
|
+
}
|
|
297
327
|
isAudioSessionInitialized = true
|
|
298
328
|
}
|
|
299
329
|
|
|
330
|
+
/// True for activation errors iOS reports when the shared session is
|
|
331
|
+
/// transiently held by another source (another app/library finishing audio,
|
|
332
|
+
/// a just-ended system call). These are worth a single retry; other errors
|
|
333
|
+
/// (bad category config, denied permission) are not.
|
|
334
|
+
private static func isTransientActivationError(_ error: NSError) -> Bool {
|
|
335
|
+
guard error.domain == NSOSStatusErrorDomain else { return false }
|
|
336
|
+
switch error.code {
|
|
337
|
+
case Int(AVAudioSession.ErrorCode.insufficientPriority.rawValue),
|
|
338
|
+
Int(AVAudioSession.ErrorCode.isBusy.rawValue),
|
|
339
|
+
Int(AVAudioSession.ErrorCode.cannotInterruptOthers.rawValue):
|
|
340
|
+
return true
|
|
341
|
+
default:
|
|
342
|
+
return false
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
300
346
|
// used for voice isolation, experimental
|
|
301
347
|
private func promptForMicrophoneModes() {
|
|
302
348
|
guard #available(iOS 15.0, *) else {
|