@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
- // Make it large enough to hold at least one full read, too.
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
- recordingThread = Thread { recordingProcess() }.apply { start() }
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 and clear recording thread
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
- emitAudioData(audioData, bytesRead)
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
- gainNormalizer.apply(audioData, bytesRead)
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
- // Emit immediately — each read is one interval of audio
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
- private fun emitAudioData(audioData: ByteArray, length: Int) {
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 = lastEmittedSize
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 totalDataSize.toLong(),
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
- promise.reject("ERROR", "Failed to init audio session: \(error.localizedDescription)")
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
- try audioSession.setActive(true)
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edkimmel/expo-audio-stream",
3
- "version": "0.6.4",
3
+ "version": "1.0.0",
4
4
  "description": "Expo Play Audio Stream module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",