@siteed/expo-audio-stream 1.6.1 → 1.7.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/CHANGELOG.md CHANGED
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [1.7.0] - 2025-01-05
12
+ - feat(playground): enhance app configuration and build setup for production deployment (#58) ([929d443](https://github.com/deeeed/expo-audio-stream/commit/929d443145378b1430d215db5c00b13758420e2b))
13
+ - chore(expo-audio-stream): release @siteed/expo-audio-stream@1.6.1 ([084e8ad](https://github.com/deeeed/expo-audio-stream/commit/084e8adb91da7874c9e608b55d9c7b2ffd7a8327))
14
+ - fix(ios): improve audio resampling and duration tracking (#69) ([51bef49](https://github.com/deeeed/expo-audio-stream/commit/51bef493b8e167852c64b8c66a9f8a14cd34f99c))
15
+ - handle paused state in stopRecording (#68) ([15eac9b](https://github.com/deeeed/expo-audio-stream/commit/15eac9bfcc3203e4a5eb5f236286ed72aafde722))
16
+ - reset audio recording state properly on iOS and Android (#66) ([61e9c26](https://github.com/deeeed/expo-audio-stream/commit/61e9c261fb3a979be1894e537233d6e5a4fbdae4))
17
+ - total size doesnt reset on new recording android (#64) ([f7da57b](https://github.com/deeeed/expo-audio-stream/commit/f7da57ba9d6f25870c130c54a049ba4cfad1c444))
18
+
11
19
  ## [1.6.1] - 2024-12-11
12
20
  - chore(expo-audio-stream): remove git commit step from publish script ([4a772ce](https://github.com/deeeed/expo-audio-stream/commit/4a772ce93bb7405d9b8e981f46bdf8941a71ecfe))
13
21
  - chore: more publishing automation ([3693021](https://github.com/deeeed/expo-audio-stream/commit/369302107f9dca9dddd8ae68e6214481a39976ac))
@@ -19,18 +27,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
27
  - fix(expo-audio-stream): missing plugin files ([e56254a](https://github.com/deeeed/expo-audio-stream/commit/e56254a4ffa1c015df3d300831ba0b392958b6c8))
20
28
  - fix(expo-audio-stream): plugin deployment process and build system enhancements (#56) ([63fbeb8](https://github.com/deeeed/expo-audio-stream/commit/63fbeb82f56130dedeafa633e916f2ce0f8f1a67))
21
29
 
30
+
22
31
  ## [1.5.0] - 2024-12-10
23
32
  - UNPUBLISHED because of a bug in the build system
24
33
 
25
34
 
35
+
26
36
  ## [1.4.0] - 2024-12-05
27
37
  - chore: remove unusded dependencies ([ad81dd5](https://github.com/deeeed/expo-audio-stream/commit/ad81dd560c93dd1d04995a323a4ae72d4de20f3e))
28
38
 
29
39
 
40
+
30
41
  ## [1.3.1] - 2024-12-05
31
42
  - feat(web): implement throttling and optimize event processing (#49) ([da28765](https://github.com/deeeed/expo-audio-stream/commit/da2876524c2c9d6e0a980fde40a0197b929d8a7f))
32
43
 
33
44
 
45
+
34
46
  ## [1.3.0] - 2024-11-28
35
47
  ### Added
36
48
  - refactor(permissions): standardize permission status response structure across platforms (#44) ([7c9c800](https://github.com/deeeed/expo-audio-stream/commit/7c9c800d83b7cea3516643371484d5e1f3b99e4c))
@@ -40,12 +52,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
40
52
  - feat: latest expo sdk ([258ef6c](https://github.com/deeeed/expo-audio-stream/commit/258ef6cf68e70c7855f696a01204f79b0793fdc0))
41
53
 
42
54
 
55
+
43
56
  ## [1.2.5] - 2024-11-12
44
57
  ### Added
45
58
  - docs(license): add MIT license to all packages (6 files changed)
46
59
  - fix(expo-audio-stream): return actual recording settings from startRecording on iOS #37
47
60
 
48
61
 
62
+
49
63
  ## [1.2.4] - 2024-11-05
50
64
  ### Changed
51
65
  - Android minimum audio interval set to 10ms.
@@ -55,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
55
69
  - Remove frequently firing log statements on web.
56
70
 
57
71
 
72
+
58
73
  ## [1.2.0] - 2024-10-24
59
74
  ### Added
60
75
  - Feature: Keep device awake during recording with `keepAwake` option
@@ -64,12 +79,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
64
79
  - iOS: Integration with media player
65
80
 
66
81
 
82
+
67
83
  ## [1.1.17] - 2024-10-21
68
84
  ### Added
69
85
  - Support bluetooth headset on ios
70
86
  - Fixes: android not reading custom interval audio update
71
87
 
72
88
 
89
+
73
90
  ## [1.0.0] - 2024-04-01
74
91
  ### Added
75
92
  - Initial release of @siteed/expo-audio-stream.
@@ -80,7 +97,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
80
97
  - Feature: Audio features extraction during recording.
81
98
  - Feature: Consistent WAV PCM recording format across all platforms.
82
99
 
83
- [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.6.1...HEAD
100
+ [unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.7.0...HEAD
101
+ [1.7.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.6.1...@siteed/expo-audio-stream@1.7.0
84
102
  [1.6.1]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.6.0...@siteed/expo-audio-stream@1.6.1
85
103
  [1.5.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.4.0...@siteed/expo-audio-stream@1.5.0
86
104
  [1.4.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-stream@1.3.1...@siteed/expo-audio-stream@1.4.0
@@ -339,6 +339,7 @@ class AudioRecorderManager(
339
339
  try {
340
340
  streamUuid = java.util.UUID.randomUUID().toString()
341
341
  audioFile = File(filesDir, "audio_${streamUuid}.$fileExtension")
342
+ totalDataSize = 0
342
343
 
343
344
  FileOutputStream(audioFile, true).use { fos ->
344
345
  audioFileHandler.writeWavHeader(
@@ -396,7 +397,6 @@ class AudioRecorderManager(
396
397
 
397
398
  fun stopRecording(promise: Promise) {
398
399
  synchronized(audioRecordLock) {
399
-
400
400
  if (!isRecording.get()) {
401
401
  Log.e(Constants.TAG, "Recording is not active")
402
402
  promise.reject("NOT_RECORDING", "Recording is not active", null)
@@ -404,16 +404,27 @@ class AudioRecorderManager(
404
404
  }
405
405
 
406
406
  try {
407
+ if (isPaused.get()) {
408
+ val remainingData = ByteArray(bufferSizeInBytes)
409
+ val bytesRead = audioRecord?.read(remainingData, 0, bufferSizeInBytes) ?: -1
410
+ if (bytesRead > 0) {
411
+ emitAudioData(remainingData.copyOfRange(0, bytesRead), bytesRead)
412
+ }
413
+ }
414
+
407
415
  if (recordingConfig.showNotification) {
408
416
  notificationManager.stopUpdates()
409
417
  AudioRecordingService.stopService(context)
410
418
  }
411
419
 
420
+ isRecording.set(false)
421
+ recordingThread?.join(1000)
422
+
412
423
  val audioData = ByteArray(bufferSizeInBytes)
413
424
  val bytesRead = audioRecord?.read(audioData, 0, bufferSizeInBytes) ?: -1
414
425
  Log.d(Constants.TAG, "Last Read $bytesRead bytes")
415
426
  if (bytesRead > 0) {
416
- emitAudioData(audioData, bytesRead)
427
+ emitAudioData(audioData.copyOfRange(0, bytesRead), bytesRead)
417
428
  }
418
429
 
419
430
  Log.d(Constants.TAG, "Stopping recording state = ${audioRecord?.state}")
@@ -35,7 +35,10 @@ class AudioStreamManager: NSObject {
35
35
  internal var recordingFileURL: URL?
36
36
  private var audioProcessor: AudioProcessor?
37
37
  private var startTime: Date?
38
- private var pauseStartTime: Date?
38
+ private var totalPausedDuration: TimeInterval = 0 // Track total paused time
39
+ private var currentPauseStart: Date? // Track current pause start
40
+ private var isRecording = false
41
+ private var isPaused = false
39
42
 
40
43
  // Wake lock related properties
41
44
  private var wasIdleTimerDisabled: Bool = false // Track previous idle timer state
@@ -45,9 +48,6 @@ class AudioStreamManager: NSObject {
45
48
  internal var lastEmittedSize: Int64 = 0
46
49
  private var emissionInterval: TimeInterval = 1.0 // Default to 1 second
47
50
  private var totalDataSize: Int64 = 0
48
- private var isRecording = false
49
- private var isPaused = false
50
- private var pausedDuration = 0
51
51
  private var fileManager = FileManager.default
52
52
  internal var recordingSettings: RecordingSettings?
53
53
  internal var recordingUUID: UUID?
@@ -66,6 +66,8 @@ class AudioStreamManager: NSObject {
66
66
 
67
67
  weak var delegate: AudioStreamManagerDelegate? // Define the delegate here
68
68
 
69
+ private var lastValidDuration: TimeInterval? // Add this property
70
+
69
71
  /// Initializes the AudioStreamManager
70
72
  override init() {
71
73
  super.init()
@@ -208,9 +210,22 @@ class AudioStreamManager: NSObject {
208
210
  }
209
211
  }
210
212
 
211
- private func currentRecordingDuration() -> TimeInterval {
212
- guard let startTime = startTime else { return 0 }
213
- return Date().timeIntervalSince(startTime) - TimeInterval(pausedDuration)
213
+ func currentRecordingDuration() -> TimeInterval {
214
+ // If we're paused, return the last valid duration
215
+ if isPaused, let lastDuration = lastValidDuration {
216
+ return lastDuration
217
+ }
218
+
219
+ guard let settings = recordingSettings else { return 0 }
220
+
221
+ // Normal duration calculation from data
222
+ let sampleRate = Double(settings.sampleRate)
223
+ let channels = Double(settings.numberOfChannels)
224
+ let bytesPerSample = Double(settings.bitDepth) / 8.0
225
+
226
+ let durationFromData = Double(totalDataSize) / (sampleRate * channels * bytesPerSample)
227
+
228
+ return durationFromData
214
229
  }
215
230
 
216
231
  private func cleanupNotificationObservers() {
@@ -261,7 +276,7 @@ class AudioStreamManager: NSObject {
261
276
  // Calculate current duration
262
277
  let currentDuration: TimeInterval
263
278
  if let startTime = startTime {
264
- currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(pausedDuration)
279
+ currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
265
280
  } else {
266
281
  currentDuration = 0
267
282
  }
@@ -282,7 +297,7 @@ class AudioStreamManager: NSObject {
282
297
  private func updateMediaInfo() {
283
298
  guard let startTime = startTime else { return }
284
299
 
285
- let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(pausedDuration)
300
+ let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
286
301
 
287
302
  var nowPlayingInfo = notificationView?.nowPlayingInfo ?? [:]
288
303
  nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentDuration
@@ -372,20 +387,13 @@ class AudioStreamManager: NSObject {
372
387
  /// Gets the current status of the recording.
373
388
  /// - Returns: A dictionary containing the recording status information.
374
389
  func getStatus() -> [String: Any] {
375
- // let currentTime = Date()
376
- // let totalRecordedTime = startTime != nil ? Int(currentTime.timeIntervalSince(startTime!)) - pausedDuration : 0
377
390
  guard let settings = recordingSettings else {
378
391
  print("Recording settings are not available.")
379
392
  return [:]
380
393
  }
381
394
 
382
- let sampleRate = Double(settings.sampleRate)
383
- let channels = Double(settings.numberOfChannels)
384
- let bitDepth = Double(settings.bitDepth)
385
-
386
- // Calculate the duration in seconds
387
- let durationInSeconds = Double(totalDataSize) / (sampleRate * channels * (bitDepth / 8))
388
- let durationInMilliseconds = Int(durationInSeconds * 1000) - Int(pausedDuration * 1000)
395
+ let durationInSeconds = currentRecordingDuration()
396
+ let durationInMilliseconds = Int(durationInSeconds * 1000)
389
397
 
390
398
  return [
391
399
  "durationMs": durationInMilliseconds,
@@ -395,7 +403,6 @@ class AudioStreamManager: NSObject {
395
403
  "size": totalDataSize,
396
404
  "interval": emissionInterval
397
405
  ]
398
-
399
406
  }
400
407
 
401
408
  /// Starts a new audio recording with the specified settings and interval.
@@ -414,38 +421,32 @@ class AudioStreamManager: NSObject {
414
421
  return nil
415
422
  }
416
423
 
417
- var newSettings = settings // Make settings mutable
418
424
  let session = AVAudioSession.sharedInstance()
425
+ var newSettings = settings
419
426
 
420
- // Determine the commonFormat based on bitDepth
421
- let commonFormat: AVAudioCommonFormat
422
- switch newSettings.bitDepth {
423
- case 16:
424
- commonFormat = .pcmFormatInt16
425
- case 32:
426
- commonFormat = .pcmFormatInt32
427
- default:
428
- Logger.debug("Unsupported bit depth. Defaulting to 16-bit PCM")
429
- commonFormat = .pcmFormatInt16
430
- newSettings.bitDepth = 16
431
- }
432
-
427
+ // Add these initializations back
433
428
  emissionInterval = max(100.0, Double(intervalMilliseconds)) / 1000.0
434
429
  lastEmissionTime = Date()
435
430
  accumulatedData.removeAll()
436
431
  totalDataSize = 0
437
- pausedDuration = 0
432
+ totalPausedDuration = 0
433
+ lastEmittedSize = 0
438
434
  isPaused = false
439
435
 
436
+ // Create recording file first
437
+ recordingFileURL = createRecordingFile()
438
+ if recordingFileURL == nil {
439
+ Logger.debug("Error: Failed to create recording file.")
440
+ return nil
441
+ }
442
+
443
+ // Then set up audio session and tap
440
444
  do {
441
- Logger.debug("Debug: Configuring audio session with sample rate: \(settings.sampleRate) Hz")
442
-
443
- // Check if the input node supports the desired format
444
- let inputNode = audioEngine.inputNode
445
- let hardwareFormat = inputNode.inputFormat(forBus: 0)
446
- if hardwareFormat.sampleRate != newSettings.sampleRate {
447
- Logger.debug("Debug: Preferred sample rate not supported. Falling back to hardware sample rate \(session.sampleRate).")
448
- newSettings.sampleRate = session.sampleRate
445
+ Logger.debug("Configuring audio session with sample rate: \(settings.sampleRate) Hz")
446
+
447
+ if let currentRoute = session.currentRoute.outputs.first {
448
+ Logger.debug("Current audio output: \(currentRoute.portType)")
449
+ newSettings.sampleRate = settings.sampleRate // Keep original sample rate
449
450
  }
450
451
 
451
452
  // Configure audio session based on iOS settings if provided
@@ -479,19 +480,47 @@ class AudioStreamManager: NSObject {
479
480
  }
480
481
 
481
482
  try session.setPreferredSampleRate(settings.sampleRate)
482
- try session.setPreferredIOBufferDuration(1024 / settings.sampleRate)
483
+ try session.setPreferredIOBufferDuration(1024 / Double(settings.sampleRate))
483
484
  try session.setActive(true)
484
- Logger.debug("Debug: Audio session activated successfully.")
485
-
485
+ Logger.debug("Audio session activated successfully.")
486
486
 
487
487
  let actualSampleRate = session.sampleRate
488
- if actualSampleRate != newSettings.sampleRate {
489
- Logger.debug("Debug: Preferred sample rate not set. Falling back to hardware sample rate: \(actualSampleRate) Hz")
490
- newSettings.sampleRate = actualSampleRate
488
+ if actualSampleRate != settings.sampleRate {
489
+ Logger.debug("Hardware using sample rate \(actualSampleRate)Hz, will resample to \(settings.sampleRate)Hz")
490
+ }
491
+
492
+ recordingSettings = newSettings // Keep original settings with desired sample rate
493
+ enableWakeLock()
494
+
495
+ // Create format matching hardware capabilities
496
+ guard let hardwareFormat = AVAudioFormat(
497
+ commonFormat: .pcmFormatFloat32,
498
+ sampleRate: actualSampleRate,
499
+ channels: AVAudioChannelCount(settings.numberOfChannels),
500
+ interleaved: true
501
+ ) else {
502
+ Logger.debug("Failed to create hardware format")
503
+ return nil
504
+ }
505
+
506
+ Logger.debug("""
507
+ Audio format configuration:
508
+ - Hardware format: \(describeAudioFormat(hardwareFormat))
509
+ - Target format: \(describeCommonFormat(hardwareFormat.commonFormat)) at \(actualSampleRate)Hz
510
+ - Bit depth: \(settings.bitDepth)-bit
511
+ - Channels: \(settings.numberOfChannels)
512
+ """)
513
+
514
+ audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] (buffer, time) in
515
+ guard let self = self,
516
+ let fileURL = self.recordingFileURL else {
517
+ Logger.debug("Error: File URL or self is nil during buffer processing.")
518
+ return
519
+ }
520
+ self.processAudioBuffer(buffer, fileURL: fileURL)
521
+ self.lastBufferTime = time
491
522
  }
492
523
 
493
- recordingSettings = newSettings // Update the class property with the new settings
494
- enableWakeLock() // Will only enable if keepAwake is true
495
524
  } catch {
496
525
  Logger.debug("Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
497
526
  return nil
@@ -499,9 +528,25 @@ class AudioStreamManager: NSObject {
499
528
 
500
529
  NotificationCenter.default.addObserver(self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil)
501
530
 
502
- // Correct the format to use 16-bit integer (PCM)
503
- guard let audioFormat = AVAudioFormat(commonFormat: commonFormat, sampleRate: newSettings.sampleRate, channels: UInt32(newSettings.numberOfChannels), interleaved: true) else {
504
- Logger.debug("Error: Failed to create audio format with the specified bit depth.")
531
+ // Create audio format based on recording settings
532
+ let commonFormat: AVAudioCommonFormat
533
+ switch newSettings.bitDepth {
534
+ case 16:
535
+ commonFormat = .pcmFormatInt16
536
+ case 32:
537
+ commonFormat = .pcmFormatFloat32
538
+ default:
539
+ Logger.debug("Unsupported bit depth: \(newSettings.bitDepth), falling back to 16-bit")
540
+ commonFormat = .pcmFormatInt16
541
+ }
542
+
543
+ guard let audioFormat = AVAudioFormat(
544
+ commonFormat: commonFormat,
545
+ sampleRate: newSettings.sampleRate,
546
+ channels: UInt32(newSettings.numberOfChannels),
547
+ interleaved: true
548
+ ) else {
549
+ Logger.debug("Error: Failed to create audio format with bit depth: \(newSettings.bitDepth)")
505
550
  return nil
506
551
  }
507
552
 
@@ -515,31 +560,16 @@ class AudioStreamManager: NSObject {
515
560
  Logger.debug("AudioProcessor activated successfully.")
516
561
  }
517
562
 
518
- audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: audioFormat) { [weak self] (buffer, time) in
519
- guard let self = self, let fileURL = self.recordingFileURL else {
520
- Logger.debug("Error: File URL or self is nil during buffer processing.")
521
- return
522
- }
523
- let formatDescription = describeAudioFormat(buffer.format)
524
- Logger.debug("Debug: Buffer format - \(formatDescription)")
525
-
526
- // Processing the current buffer
527
- self.processAudioBuffer(buffer, fileURL: self.recordingFileURL!)
528
- self.lastBufferTime = time
529
- }
530
-
531
- recordingFileURL = createRecordingFile()
532
- if recordingFileURL == nil {
533
- Logger.debug("Error: Failed to create recording file.")
534
- return nil
535
- }
536
-
537
563
  if settings.showNotification {
538
564
  initializeNotifications()
539
565
  }
540
566
 
541
567
  do {
542
568
  startTime = Date()
569
+ totalPausedDuration = 0 // Reset pause tracking
570
+ currentPauseStart = nil
571
+ Logger.debug("Starting new recording - Reset pause tracking")
572
+
543
573
  try audioEngine.start()
544
574
  isRecording = true
545
575
  isPaused = false
@@ -562,17 +592,18 @@ class AudioStreamManager: NSObject {
562
592
  func pauseRecording() {
563
593
  guard isRecording && !isPaused else { return }
564
594
 
595
+ // Store the current duration when pausing
596
+ lastValidDuration = currentRecordingDuration()
597
+ Logger.debug("Storing duration at pause: \(lastValidDuration ?? 0)")
598
+
565
599
  disableWakeLock()
566
600
  audioEngine.pause()
567
601
  isPaused = true
568
- pauseStartTime = Date()
569
602
 
570
603
  updateNowPlayingInfo(isPaused: true)
571
604
  notificationManager?.updateState(isPaused: true)
572
605
  delegate?.audioStreamManager(self, didPauseRecording: Date())
573
606
  delegate?.audioStreamManager(self, didUpdateNotificationState: true)
574
-
575
- Logger.debug("Recording paused.")
576
607
  }
577
608
 
578
609
  private func initializeNotifications() {
@@ -627,21 +658,32 @@ class AudioStreamManager: NSObject {
627
658
  func resumeRecording() {
628
659
  guard isRecording && isPaused else { return }
629
660
 
661
+ lastValidDuration = nil // Clear the stored duration when resuming
662
+
630
663
  enableWakeLock()
631
664
  audioEngine.prepare()
632
665
  do {
633
666
  try audioEngine.start()
634
- isPaused = false
635
- if let pauseStartTime = pauseStartTime {
636
- pausedDuration += Int(Date().timeIntervalSince(pauseStartTime))
667
+
668
+ // Add the completed pause duration to total
669
+ if let pauseStart = currentPauseStart {
670
+ let currentPauseDuration = Date().timeIntervalSince(pauseStart)
671
+ totalPausedDuration += currentPauseDuration
672
+ currentPauseStart = nil
673
+
674
+ Logger.debug("""
675
+ Resume completed:
676
+ - Added pause duration: \(currentPauseDuration)
677
+ - New total pause duration: \(totalPausedDuration)
678
+ """)
637
679
  }
638
680
 
681
+ isPaused = false
682
+
639
683
  updateNowPlayingInfo(isPaused: false)
640
684
  notificationManager?.updateState(isPaused: false)
641
685
  delegate?.audioStreamManager(self, didResumeRecording: Date())
642
686
  delegate?.audioStreamManager(self, didUpdateNotificationState: false)
643
-
644
- Logger.debug("Recording resumed.")
645
687
  } catch {
646
688
  Logger.debug("Error: Failed to resume recording: \(error.localizedDescription)")
647
689
  }
@@ -651,32 +693,40 @@ class AudioStreamManager: NSObject {
651
693
  /// - Parameter format: The AVAudioFormat object to describe.
652
694
  /// - Returns: A string description of the audio format.
653
695
  func describeAudioFormat(_ format: AVAudioFormat) -> String {
654
- let sampleRate = format.sampleRate
655
- let channelCount = format.channelCount
656
- let bitDepth: String
657
-
658
- switch format.commonFormat {
659
- case .pcmFormatInt16:
660
- bitDepth = "16-bit Int"
661
- case .pcmFormatInt32:
662
- bitDepth = "32-bit Int"
696
+ let formatDescription = """
697
+ - Sample rate: \(format.sampleRate)Hz
698
+ - Channels: \(format.channelCount)
699
+ - Interleaved: \(format.isInterleaved)
700
+ - Common format: \(describeCommonFormat(format.commonFormat))
701
+ """
702
+ return formatDescription
703
+ }
704
+
705
+ private func describeCommonFormat(_ format: AVAudioCommonFormat) -> String {
706
+ switch format {
663
707
  case .pcmFormatFloat32:
664
- bitDepth = "32-bit Float"
708
+ return "32-bit float"
665
709
  case .pcmFormatFloat64:
666
- bitDepth = "64-bit Float"
710
+ return "64-bit float"
711
+ case .pcmFormatInt16:
712
+ return "16-bit int"
713
+ case .pcmFormatInt32:
714
+ return "32-bit int"
667
715
  default:
668
- bitDepth = "Unknown Format"
716
+ return "Unknown format"
669
717
  }
670
-
671
- return "Sample Rate: \(sampleRate), Channels: \(channelCount), Format: \(bitDepth)"
672
718
  }
673
719
 
674
720
  /// Stops the current audio recording.
675
721
  /// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
676
722
  func stopRecording() -> RecordingResult? {
677
- disableWakeLock() // Will only disable if keepAwake is true
723
+ disableWakeLock()
678
724
  audioEngine.stop()
679
725
  audioEngine.inputNode.removeTap(onBus: 0)
726
+
727
+ // Get the final duration before changing state
728
+ let finalDuration = currentRecordingDuration()
729
+
680
730
  isRecording = false
681
731
  isPaused = false
682
732
 
@@ -701,21 +751,14 @@ class AudioStreamManager: NSObject {
701
751
  try? audioSession?.setActive(false)
702
752
  }
703
753
 
704
- guard let fileURL = recordingFileURL, let startTime = startTime, let settings = recordingSettings else {
754
+ guard let fileURL = recordingFileURL,
755
+ let settings = recordingSettings else {
705
756
  Logger.debug("Recording or file URL is nil.")
706
757
  return nil
707
758
  }
708
759
 
709
- // Emit any remaining accumulated data
710
- if !accumulatedData.isEmpty {
711
- let currentTime = Date()
712
- let recordingTime = currentTime.timeIntervalSince(startTime)
713
- delegate?.audioStreamManager(self, didReceiveAudioData: accumulatedData, recordingTime: recordingTime, totalDataSize: totalDataSize)
714
- accumulatedData.removeAll()
715
- }
716
-
717
- let endTime = Date()
718
- let duration = Int64(endTime.timeIntervalSince(startTime) * 1000) - Int64(pausedDuration * 1000)
760
+ // Use the final duration we captured before state changes
761
+ let durationMs = Int64(finalDuration * 1000)
719
762
 
720
763
  // Calculate the total size of audio data written to the file
721
764
  let filePath = fileURL.path
@@ -736,14 +779,15 @@ class AudioStreamManager: NSObject {
736
779
  fileUri: fileURL.absoluteString,
737
780
  filename: fileURL.lastPathComponent,
738
781
  mimeType: mimeType,
739
- duration: duration,
782
+ duration: durationMs,
740
783
  size: fileSize,
741
784
  channels: settings.numberOfChannels,
742
785
  bitDepth: settings.bitDepth,
743
786
  sampleRate: settings.sampleRate
744
787
  )
745
- recordingFileURL = nil // Reset for next recording
746
- lastBufferTime = nil // Reset last buffer time
788
+ recordingFileURL = nil
789
+ lastBufferTime = nil
790
+ lastValidDuration = nil
747
791
 
748
792
  return result
749
793
  } catch {
@@ -759,34 +803,131 @@ class AudioStreamManager: NSObject {
759
803
  /// - targetSampleRate: The desired sample rate to resample to.
760
804
  /// - Returns: A new audio buffer resampled to the target sample rate, or nil if resampling fails.
761
805
  private func resampleAudioBuffer(_ buffer: AVAudioPCMBuffer, from originalSampleRate: Double, to targetSampleRate: Double) -> AVAudioPCMBuffer? {
762
- guard let channelData = buffer.floatChannelData else { return nil }
763
-
764
- let sourceFrameCount = Int(buffer.frameLength)
765
- let sourceChannels = Int(buffer.format.channelCount)
766
-
767
- // Calculate the number of frames in the target buffer
768
- let targetFrameCount = Int(Double(sourceFrameCount) * targetSampleRate / originalSampleRate)
806
+ guard let settings = recordingSettings else {
807
+ Logger.debug("Recording settings not available")
808
+ return nil
809
+ }
769
810
 
770
- // Create a new audio buffer for the resampled data
771
- guard let targetBuffer = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: AVAudioFrameCount(targetFrameCount)) else { return nil }
772
- targetBuffer.frameLength = AVAudioFrameCount(targetFrameCount)
811
+ Logger.debug("""
812
+ Starting resampling:
813
+ - Original format: \(describeAudioFormat(buffer.format))
814
+ - Original frames: \(buffer.frameLength)
815
+ - Target settings:
816
+ • Sample rate: \(targetSampleRate)Hz
817
+ • Bit depth: \(settings.bitDepth)
818
+ • Channels: \(settings.numberOfChannels)
819
+ """)
820
+
821
+ // Use settings bit depth for output format
822
+ let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
823
+
824
+ // Create output format matching recording settings exactly
825
+ guard let outputFormat = AVAudioFormat(
826
+ commonFormat: targetFormat,
827
+ sampleRate: targetSampleRate,
828
+ channels: AVAudioChannelCount(settings.numberOfChannels),
829
+ interleaved: true
830
+ ) else {
831
+ Logger.debug("Failed to create output format")
832
+ return nil
833
+ }
773
834
 
774
- let resamplingFactor = Float(targetSampleRate / originalSampleRate) // Factor to resample the audio
835
+ // Calculate new buffer size
836
+ let ratio = targetSampleRate / originalSampleRate
837
+ let newFrameCount = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
775
838
 
776
- for channel in 0..<sourceChannels {
777
- let input = UnsafeBufferPointer(start: channelData[channel], count: sourceFrameCount) // Original channel data
778
- let output = UnsafeMutableBufferPointer(start: targetBuffer.floatChannelData![channel], count: targetFrameCount) // Buffer for resampled data
839
+ // Create output buffer
840
+ guard let outputBuffer = AVAudioPCMBuffer(
841
+ pcmFormat: outputFormat,
842
+ frameCapacity: newFrameCount
843
+ ) else {
844
+ Logger.debug("Failed to create output buffer")
845
+ return nil
846
+ }
847
+ outputBuffer.frameLength = newFrameCount
848
+
849
+ // Create intermediate format for high-quality conversion if needed
850
+ let needsIntermediate = buffer.format.commonFormat != outputFormat.commonFormat
851
+ if needsIntermediate {
852
+ Logger.debug("Using intermediate Float32 format for high-quality conversion")
853
+ guard let intermediateFormat = AVAudioFormat(
854
+ commonFormat: .pcmFormatFloat32,
855
+ sampleRate: targetSampleRate,
856
+ channels: AVAudioChannelCount(settings.numberOfChannels),
857
+ interleaved: true
858
+ ) else {
859
+ Logger.debug("Failed to create intermediate format")
860
+ return nil
861
+ }
862
+
863
+ // First convert to intermediate float format
864
+ guard let converter = AVAudioConverter(from: buffer.format, to: intermediateFormat),
865
+ let intermediateBuffer = AVAudioPCMBuffer(
866
+ pcmFormat: intermediateFormat,
867
+ frameCapacity: newFrameCount
868
+ ) else {
869
+ Logger.debug("Failed to create converter or intermediate buffer")
870
+ return nil
871
+ }
872
+ intermediateBuffer.frameLength = newFrameCount
779
873
 
780
- var y: [Float] = Array(repeating: 0, count: targetFrameCount) // Temporary array for resampled data
874
+ var error: NSError?
875
+ let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
876
+ outStatus.pointee = AVAudioConverterInputStatus.haveData
877
+ return buffer
878
+ }
781
879
 
782
- // Resample using vDSP_vgenp which performs interpolation
783
- vDSP_vgenp(input.baseAddress!, vDSP_Stride(1), [Float](stride(from: 0, to: Float(sourceFrameCount), by: resamplingFactor)), vDSP_Stride(1), &y, vDSP_Stride(1), vDSP_Length(targetFrameCount), vDSP_Length(sourceFrameCount))
880
+ converter.convert(to: intermediateBuffer, error: &error, withInputFrom: inputBlock)
784
881
 
785
- for i in 0..<targetFrameCount {
786
- output[i] = y[i]
882
+ if let error = error {
883
+ Logger.debug("Intermediate conversion failed: \(error.localizedDescription)")
884
+ return nil
885
+ }
886
+
887
+ // Then convert to final format
888
+ guard let finalConverter = AVAudioConverter(from: intermediateFormat, to: outputFormat) else {
889
+ Logger.debug("Failed to create final converter")
890
+ return nil
891
+ }
892
+
893
+ finalConverter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
894
+ outStatus.pointee = AVAudioConverterInputStatus.haveData
895
+ return intermediateBuffer
896
+ }
897
+
898
+ if let error = error {
899
+ Logger.debug("Final conversion failed: \(error.localizedDescription)")
900
+ return nil
901
+ }
902
+ } else {
903
+ // Direct conversion if formats are compatible
904
+ guard let converter = AVAudioConverter(from: buffer.format, to: outputFormat) else {
905
+ Logger.debug("Failed to create converter")
906
+ return nil
907
+ }
908
+
909
+ var error: NSError?
910
+ let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
911
+ outStatus.pointee = AVAudioConverterInputStatus.haveData
912
+ return buffer
913
+ }
914
+
915
+ converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
916
+
917
+ if let error = error {
918
+ Logger.debug("Conversion failed: \(error.localizedDescription)")
919
+ return nil
787
920
  }
788
921
  }
789
- return targetBuffer
922
+
923
+ Logger.debug("""
924
+ Resampling completed:
925
+ - Final format: \(describeAudioFormat(outputBuffer.format))
926
+ - Final frames: \(outputBuffer.frameLength)
927
+ - Conversion path: \(needsIntermediate ? "With intermediate Float32" : "Direct")
928
+ """)
929
+
930
+ return outputBuffer
790
931
  }
791
932
 
792
933
  /// Manually resamples the audio buffer using linear interpolation.
@@ -868,7 +1009,7 @@ class AudioStreamManager: NSObject {
868
1009
  guard let startTime = startTime,
869
1010
  recordingSettings?.showNotification == true else { return }
870
1011
 
871
- let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(pausedDuration)
1012
+ let currentDuration = Date().timeIntervalSince(startTime) - TimeInterval(totalPausedDuration)
872
1013
 
873
1014
  // Update both notification manager and media player
874
1015
  notificationManager?.updateDuration(currentDuration)
@@ -885,25 +1026,50 @@ class AudioStreamManager: NSObject {
885
1026
  /// - buffer: The audio buffer to process.
886
1027
  /// - fileURL: The URL of the file to write the data to.
887
1028
  private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, fileURL: URL) {
1029
+ guard let settings = recordingSettings else {
1030
+ Logger.debug("Recording settings not available")
1031
+ return
1032
+ }
1033
+
888
1034
  guard let fileHandle = try? FileHandle(forWritingTo: fileURL) else {
889
1035
  Logger.debug("Failed to open file handle for URL: \(fileURL)")
890
1036
  return
891
1037
  }
1038
+ defer {
1039
+ fileHandle.closeFile() // Ensure file is always closed
1040
+ }
892
1041
 
893
- let targetSampleRate = recordingSettings?.desiredSampleRate ?? buffer.format.sampleRate
894
- let finalBuffer: AVAudioPCMBuffer
1042
+ let targetSampleRate = Double(settings.sampleRate)
1043
+ let targetFormat: AVAudioCommonFormat = settings.bitDepth == 32 ? .pcmFormatFloat32 : .pcmFormatInt16
895
1044
 
1045
+ // First handle resampling if needed
1046
+ let resampledBuffer: AVAudioPCMBuffer
896
1047
  if buffer.format.sampleRate != targetSampleRate {
897
- // Resample the audio buffer if the target sample rate is different from the input sample rate
898
- if let resampledBuffer = resampleAudioBuffer(buffer, from: buffer.format.sampleRate, to: targetSampleRate) {
899
- finalBuffer = resampledBuffer
1048
+ if let resampled = resampleAudioBuffer(buffer, from: buffer.format.sampleRate, to: targetSampleRate) {
1049
+ resampledBuffer = resampled
900
1050
  } else {
901
- Logger.debug("Failed to resample audio buffer. Using original buffer.")
902
- finalBuffer = buffer
1051
+ Logger.debug("Resampling failed")
1052
+ return
1053
+ }
1054
+ } else {
1055
+ resampledBuffer = buffer
1056
+ }
1057
+
1058
+ // Then ensure format matches user settings
1059
+ let finalBuffer: AVAudioPCMBuffer
1060
+ if resampledBuffer.format.commonFormat != targetFormat {
1061
+ guard let converted = convertBufferFormat(resampledBuffer, to: AVAudioFormat(
1062
+ commonFormat: targetFormat,
1063
+ sampleRate: targetSampleRate,
1064
+ channels: AVAudioChannelCount(settings.numberOfChannels),
1065
+ interleaved: true
1066
+ )!) else {
1067
+ Logger.debug("Format conversion failed")
1068
+ return
903
1069
  }
1070
+ finalBuffer = converted
904
1071
  } else {
905
- // Use the original buffer if the sample rates are the same
906
- finalBuffer = buffer
1072
+ finalBuffer = resampledBuffer
907
1073
  }
908
1074
 
909
1075
  let audioData = finalBuffer.audioBufferList.pointee.mBuffers
@@ -911,74 +1077,118 @@ class AudioStreamManager: NSObject {
911
1077
  Logger.debug("Buffer data is nil.")
912
1078
  return
913
1079
  }
1080
+
914
1081
  var data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
915
1082
 
916
- // Check if this is the first buffer to process and totalDataSize is 0
1083
+ // Check if this is the first buffer to process
917
1084
  if totalDataSize == 0 {
918
- // Since it's the first buffer, prepend the WAV header
919
- let header = createWavHeader(dataSize: 0) // Set initial dataSize to 0, update later
1085
+ let header = createWavHeader(dataSize: 0)
920
1086
  data.insert(contentsOf: header, at: 0)
921
1087
  }
922
1088
 
923
- // Accumulate new data
924
- accumulatedData.append(data)
925
-
926
- // print("Writing data size: \(data.count) bytes") // Debug: Check the size of data being written
1089
+ // Write to file
927
1090
  fileHandle.seekToEndOfFile()
928
1091
  fileHandle.write(data)
929
- fileHandle.closeFile()
930
1092
 
1093
+ // Update total size and accumulated data
931
1094
  totalDataSize += Int64(data.count)
932
- // print("Total data size written: \(totalDataSize) bytes") // Debug: Check total data written
1095
+ accumulatedData.append(data)
933
1096
 
1097
+ // Handle notifications if enabled
934
1098
  if recordingSettings?.showNotification == true {
935
1099
  updateNotificationDuration()
936
1100
  }
937
1101
 
1102
+ // Emit data based on interval
938
1103
  let currentTime = Date()
939
- if let lastEmissionTime = lastEmissionTime, currentTime.timeIntervalSince(lastEmissionTime) >= emissionInterval {
940
- if let startTime = startTime {
941
- let recordingTime = currentTime.timeIntervalSince(startTime)
942
- // Copy accumulated data for processing
943
- let dataToProcess = accumulatedData
944
-
945
- // Emit the processed audio data
946
- self.delegate?.audioStreamManager(self, didReceiveAudioData: dataToProcess, recordingTime: recordingTime, totalDataSize: totalDataSize)
947
-
948
- if recordingSettings?.enableProcessing == true {
949
- // Process the copied data and emit result
950
- DispatchQueue.global().async {
951
- if let processor = self.audioProcessor, let settings = self.recordingSettings {
952
- Logger.debug("processAudioBuffer with dataToProcess size --> \(dataToProcess.count)")
953
-
954
- let processingResult = processor.processAudioBuffer(
955
- data: dataToProcess,
956
- sampleRate: Float(settings.sampleRate),
957
- pointsPerSecond: settings.pointsPerSecond ?? 10,
958
- algorithm: settings.algorithm ?? "rms",
959
- featureOptions: settings.featureOptions ?? ["rms": true, "zcr": true],
960
- bitDepth: settings.bitDepth,
961
- numberOfChannels: settings.numberOfChannels
962
- )
963
- Logger.debug("processingResult \(String(describing: processingResult))")
964
-
965
- DispatchQueue.main.async {
966
- if let result = processingResult {
967
- self.delegate?.audioStreamManager(self, didReceiveProcessingResult: result)
968
- } else {
969
- Logger.debug("Processing failed or returned nil.")
970
- }
1104
+ if let lastEmissionTime = lastEmissionTime,
1105
+ let startTime = startTime,
1106
+ currentTime.timeIntervalSince(lastEmissionTime) >= emissionInterval {
1107
+
1108
+ let recordingTime = currentTime.timeIntervalSince(startTime)
1109
+ let dataToProcess = accumulatedData
1110
+
1111
+ // Emit the audio data
1112
+ delegate?.audioStreamManager(self, didReceiveAudioData: dataToProcess, recordingTime: recordingTime, totalDataSize: totalDataSize)
1113
+
1114
+ // Process audio if enabled
1115
+ if settings.enableProcessing {
1116
+ DispatchQueue.global().async { [weak self] in
1117
+ guard let self = self else { return }
1118
+ if let processor = self.audioProcessor {
1119
+ Logger.debug("Processing audio buffer of size: \(dataToProcess.count)")
1120
+ let processingResult = processor.processAudioBuffer(
1121
+ data: dataToProcess,
1122
+ sampleRate: Float(settings.sampleRate),
1123
+ pointsPerSecond: settings.pointsPerSecond ?? 10,
1124
+ algorithm: settings.algorithm ?? "rms",
1125
+ featureOptions: settings.featureOptions ?? ["rms": true, "zcr": true],
1126
+ bitDepth: settings.bitDepth,
1127
+ numberOfChannels: settings.numberOfChannels
1128
+ )
1129
+
1130
+ DispatchQueue.main.async {
1131
+ if let result = processingResult {
1132
+ self.delegate?.audioStreamManager(self, didReceiveProcessingResult: result)
971
1133
  }
972
1134
  }
973
1135
  }
974
1136
  }
975
-
976
- self.lastEmissionTime = currentTime // Update last emission time
977
- self.lastEmittedSize = totalDataSize
978
- accumulatedData.removeAll() // Reset accumulated data after emission
979
1137
  }
1138
+
1139
+ // Update state after emission
1140
+ self.lastEmissionTime = currentTime
1141
+ self.lastEmittedSize = totalDataSize
1142
+ accumulatedData.removeAll()
980
1143
  }
981
1144
  }
1145
+
1146
+ // Add helper function to calculate average amplitude
1147
+ private func calculateAverageAmplitude(_ data: UnsafePointer<Float>, count: Int) -> Float {
1148
+ var sum: Float = 0
1149
+ vDSP_meanv(data, 1, &sum, vDSP_Length(count))
1150
+ return sum
1151
+ }
1152
+
1153
+ // Add helper function to calculate RMS
1154
+ private func calculateRMS(_ data: UnsafePointer<Float>, count: Int) -> Float {
1155
+ var sum: Float = 0
1156
+ var squaredSum: Float = 0
1157
+ for i in 0..<count {
1158
+ let value = data[i]
1159
+ sum += value
1160
+ squaredSum += value * value
1161
+ }
1162
+ let average = sum / Float(count)
1163
+ let variance = squaredSum / Float(count) - average * average
1164
+ return sqrt(variance)
1165
+ }
1166
+
1167
+ // Helper function for format conversion
1168
+ private func convertBufferFormat(_ buffer: AVAudioPCMBuffer, to targetFormat: AVAudioFormat) -> AVAudioPCMBuffer? {
1169
+ guard let converter = AVAudioConverter(from: buffer.format, to: targetFormat),
1170
+ let outputBuffer = AVAudioPCMBuffer(
1171
+ pcmFormat: targetFormat,
1172
+ frameCapacity: buffer.frameLength
1173
+ ) else {
1174
+ return nil
1175
+ }
1176
+
1177
+ outputBuffer.frameLength = buffer.frameLength
1178
+ var error: NSError?
1179
+
1180
+ converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
1181
+ outStatus.pointee = AVAudioConverterInputStatus.haveData
1182
+ return buffer
1183
+ }
1184
+
1185
+ if let error = error {
1186
+ Logger.debug("Format conversion failed: \(error.localizedDescription)")
1187
+ return nil
1188
+ }
1189
+
1190
+ return outputBuffer
1191
+ }
982
1192
  }
983
1193
 
984
1194
  extension AudioStreamManager: UNUserNotificationCenterDelegate {
@@ -317,13 +317,13 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
317
317
  let sampleRate = settings.sampleRate
318
318
  let channels = Double(settings.numberOfChannels)
319
319
  let bitDepth = Double(settings.bitDepth)
320
- let position = Int((Double(manager.lastEmittedSize) / (sampleRate * channels * (bitDepth / 8))) * 1000)
320
+ let position = Int(manager.currentRecordingDuration() * 1000)
321
321
 
322
322
  // Construct the event payload similar to Android
323
323
  let eventBody: [String: Any] = [
324
324
  "fileUri": fileURL.absoluteString,
325
325
  "lastEmittedSize": manager.lastEmittedSize, // Needs to be maintained within AudioStreamManager
326
- "position": position, // Add position of the chunk in ms since
326
+ "position": position, // time in ms based on pause-aware duration
327
327
  "encoded": encodedData,
328
328
  "deltaSize": deltaSize,
329
329
  "totalSize": fileSize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-stream",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "stream audio crossplatform",
5
5
  "license": "MIT",
6
6
  "main": "build/index.js",
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "scripts": {
51
51
  "build": "tsc",
52
- "build:plugin": "yarn tsc --build plugin/tsconfig.json",
52
+ "build:plugin": "tsc --build plugin/tsconfig.json",
53
53
  "build:plugin:dev": "expo-module build plugin",
54
54
  "build:dev": "expo-module build",
55
55
  "clean": "expo-module clean && rimraf plugin/build",
@@ -57,7 +57,7 @@
57
57
  "test": "expo-module test",
58
58
  "typecheck": "tsc --noEmit",
59
59
  "docgen": "typedoc src/index.ts --plugin typedoc-plugin-markdown --readme none --out ../../documentation_site/docs/api-reference/API",
60
- "prepare": "expo-module prepare",
60
+ "prepare": "yarn build && yarn build:plugin",
61
61
  "prepublishOnly": "expo-module prepublishOnly",
62
62
  "expo-module": "expo-module",
63
63
  "open:ios": "open -a \"Xcode\" ../../apps/playground/ios",