sezo-audio-engine 0.0.4 → 0.0.5

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.
@@ -2,8 +2,11 @@ import AVFoundation
2
2
 
3
3
  final class AudioSessionManager {
4
4
  private let session = AVAudioSession.sharedInstance()
5
+ private var lastError: String?
5
6
 
6
- func configure(with config: AudioEngineConfig) {
7
+ @discardableResult
8
+ func configure(with config: AudioEngineConfig) -> Bool {
9
+ lastError = nil
7
10
  do {
8
11
  try session.setCategory(
9
12
  .playAndRecord,
@@ -11,25 +14,42 @@ final class AudioSessionManager {
11
14
  options: [.defaultToSpeaker, .allowBluetooth]
12
15
  )
13
16
  } catch {
14
- return
17
+ lastError = "setCategory failed: \(error.localizedDescription)"
18
+ return false
15
19
  }
16
20
 
17
21
  if let sampleRate = config.sampleRate {
18
- try? session.setPreferredSampleRate(sampleRate)
22
+ do {
23
+ try session.setPreferredSampleRate(sampleRate)
24
+ } catch {
25
+ lastError = "setPreferredSampleRate failed: \(error.localizedDescription)"
26
+ }
19
27
  }
20
28
 
21
29
  if let bufferSize = config.bufferSize {
22
30
  let sampleRate = config.sampleRate ?? session.sampleRate
23
31
  if sampleRate > 0 {
24
32
  let duration = bufferSize / sampleRate
25
- try? session.setPreferredIOBufferDuration(duration)
33
+ do {
34
+ try session.setPreferredIOBufferDuration(duration)
35
+ } catch {
36
+ lastError = "setPreferredIOBufferDuration failed: \(error.localizedDescription)"
37
+ }
26
38
  }
27
39
  }
28
40
 
29
- try? session.setActive(true)
41
+ do {
42
+ try session.setActive(true)
43
+ } catch {
44
+ lastError = "setActive failed: \(error.localizedDescription)"
45
+ return false
46
+ }
47
+ return true
30
48
  }
31
49
 
32
- func enableBackgroundPlayback(with config: AudioEngineConfig) {
50
+ @discardableResult
51
+ func enableBackgroundPlayback(with config: AudioEngineConfig) -> Bool {
52
+ lastError = nil
33
53
  do {
34
54
  try session.setCategory(
35
55
  .playback,
@@ -37,22 +57,41 @@ final class AudioSessionManager {
37
57
  options: [.allowBluetooth, .allowAirPlay]
38
58
  )
39
59
  } catch {
40
- return
60
+ lastError = "setCategory failed: \(error.localizedDescription)"
61
+ return false
41
62
  }
42
63
 
43
64
  if let sampleRate = config.sampleRate {
44
- try? session.setPreferredSampleRate(sampleRate)
65
+ do {
66
+ try session.setPreferredSampleRate(sampleRate)
67
+ } catch {
68
+ lastError = "setPreferredSampleRate failed: \(error.localizedDescription)"
69
+ }
45
70
  }
46
71
 
47
72
  if let bufferSize = config.bufferSize {
48
73
  let sampleRate = config.sampleRate ?? session.sampleRate
49
74
  if sampleRate > 0 {
50
75
  let duration = bufferSize / sampleRate
51
- try? session.setPreferredIOBufferDuration(duration)
76
+ do {
77
+ try session.setPreferredIOBufferDuration(duration)
78
+ } catch {
79
+ lastError = "setPreferredIOBufferDuration failed: \(error.localizedDescription)"
80
+ }
52
81
  }
53
82
  }
54
83
 
55
- try? session.setActive(true)
84
+ do {
85
+ try session.setActive(true)
86
+ } catch {
87
+ lastError = "setActive failed: \(error.localizedDescription)"
88
+ return false
89
+ }
90
+ return true
91
+ }
92
+
93
+ func lastErrorDescription() -> String? {
94
+ return lastError
56
95
  }
57
96
 
58
97
  func deactivate() {
@@ -30,6 +30,11 @@ final class NativeAudioEngine {
30
30
  private var activePlaybackCount = 0
31
31
  private var playbackToken = UUID()
32
32
  private var recordingState: RecordingState?
33
+ private var lastRecordingError: String?
34
+ private var lastEngineError: String?
35
+ private let recordingMixer = AVAudioMixerNode()
36
+ private var recordingMixerConnected = false
37
+ private var recordingTapInstalled = false
33
38
  private var inputLevel: Double = 0.0
34
39
  private var backgroundPlaybackEnabled = false
35
40
  private var nowPlayingMetadata: [String: Any] = [:]
@@ -53,7 +58,11 @@ final class NativeAudioEngine {
53
58
  let parsedConfig = AudioEngineConfig(dictionary: config)
54
59
  queue.sync {
55
60
  lastConfig = parsedConfig
56
- sessionManager.configure(with: parsedConfig)
61
+ _ = sessionManager.configure(with: parsedConfig)
62
+ _ = ensureRecordingMixerConnected(
63
+ sampleRate: AVAudioSession.sharedInstance().sampleRate,
64
+ channels: AVAudioSession.sharedInstance().inputNumberOfChannels
65
+ )
57
66
  engine.mainMixerNode.outputVolume = Float(masterVolume)
58
67
  engine.prepare()
59
68
  isInitialized = true
@@ -68,6 +77,7 @@ final class NativeAudioEngine {
68
77
  stopRecordingInternal()
69
78
  detachAllTracks()
70
79
  tracks.removeAll()
80
+ detachRecordingMixer()
71
81
  isPlayingFlag = false
72
82
  isRecordingFlag = false
73
83
  currentPositionMs = 0.0
@@ -294,23 +304,47 @@ final class NativeAudioEngine {
294
304
  func startRecording(config: [String: Any]?) -> Bool {
295
305
  return queue.sync {
296
306
  guard !isRecordingFlag else { return true }
307
+ lastRecordingError = nil
308
+ let session = AVAudioSession.sharedInstance()
309
+ let permission = session.recordPermission
310
+ if permission != .granted {
311
+ if permission == .undetermined {
312
+ requestRecordPermission()
313
+ lastRecordingError = "microphone permission not determined"
314
+ } else {
315
+ lastRecordingError = "microphone permission denied"
316
+ }
317
+ return false
318
+ }
319
+ if !sessionManager.configure(with: lastConfig) {
320
+ lastRecordingError = sessionManager.lastErrorDescription() ?? "audio session configuration failed"
321
+ return false
322
+ }
323
+ if !session.isInputAvailable {
324
+ let routeInfo = describeRoute(session)
325
+ lastRecordingError = "audio input not available (category=\(session.category.rawValue), route=\(routeInfo))"
326
+ return false
327
+ }
297
328
  ensureInitializedIfNeeded()
298
329
 
299
- let inputNode = engine.inputNode
300
- var inputFormat = inputNode.outputFormat(forBus: 0)
301
- var attempts = 0
302
- while (inputFormat.sampleRate == 0 || inputFormat.channelCount == 0) && attempts < 3 {
303
- sessionManager.configure(with: lastConfig)
304
- Thread.sleep(forTimeInterval: 0.05)
305
- inputFormat = inputNode.outputFormat(forBus: 0)
306
- attempts += 1
330
+ guard startEngineIfNeeded() else {
331
+ lastRecordingError = lastEngineError ?? "audio engine failed to start"
332
+ return false
307
333
  }
308
- guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
334
+
335
+ let sampleRate = session.sampleRate
336
+ let channels = max(1, session.inputNumberOfChannels)
337
+ if !ensureRecordingMixerConnected(sampleRate: sampleRate, channels: channels) {
338
+ return false
339
+ }
340
+
341
+ let tapFormat = recordingMixer.outputFormat(forBus: 0)
342
+ guard tapFormat.sampleRate > 0, tapFormat.channelCount > 0 else {
343
+ let routeInfo = describeRoute(session)
344
+ lastRecordingError = "input format invalid (sampleRate=\(tapFormat.sampleRate), channels=\(tapFormat.channelCount), sessionRate=\(session.sampleRate), sessionChannels=\(session.inputNumberOfChannels), route=\(routeInfo))"
309
345
  return false
310
346
  }
311
347
  let requestedFormat = config?["format"] as? String ?? "aac"
312
- let channels = Int(inputFormat.channelCount)
313
- let sampleRate = inputFormat.sampleRate
314
348
  let bitrate = resolveBitrate(config: config)
315
349
  let formatInfo = resolveRecordingFormat(requestedFormat: requestedFormat)
316
350
  let outputURL = resolveOutputURL(
@@ -350,35 +384,28 @@ final class NativeAudioEngine {
350
384
  startTimeMs: startTimeMs
351
385
  )
352
386
 
353
- guard startEngineIfNeeded() else {
354
- recordingState = nil
355
- return false
356
- }
357
-
358
- inputNode.removeTap(onBus: 0)
359
- inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
360
- guard let self = self else { return }
361
- self.queue.async {
362
- guard let state = self.recordingState else { return }
363
- self.applyRecordingGain(buffer: buffer)
364
- self.updateInputLevel(buffer: buffer)
365
- do {
366
- try state.file.write(from: buffer)
367
- } catch {
368
- return
369
- }
370
- }
371
- }
372
-
373
387
  isRecordingFlag = true
374
388
  return true
375
389
  } catch {
390
+ lastRecordingError = "recording setup failed: \(error.localizedDescription)"
376
391
  recordingState = nil
377
392
  return false
378
393
  }
379
394
  }
380
395
  }
381
396
 
397
+ private func requestRecordPermission() {
398
+ let session = AVAudioSession.sharedInstance()
399
+ let requestBlock = {
400
+ session.requestRecordPermission { _ in }
401
+ }
402
+ if Thread.isMainThread {
403
+ requestBlock()
404
+ } else {
405
+ DispatchQueue.main.async(execute: requestBlock)
406
+ }
407
+ }
408
+
382
409
  /// Stops recording and returns metadata about the output file.
383
410
  func stopRecording() -> [String: Any] {
384
411
  return queue.sync {
@@ -424,6 +451,10 @@ final class NativeAudioEngine {
424
451
  }
425
452
  }
426
453
 
454
+ func getLastRecordingError() -> String? {
455
+ return queue.sync { lastRecordingError }
456
+ }
457
+
427
458
  /// Offline export for a single track.
428
459
  func extractTrack(trackId: String, config: [String: Any]?) -> [String: Any] {
429
460
  return queue.sync {
@@ -492,7 +523,7 @@ final class NativeAudioEngine {
492
523
  queue.sync {
493
524
  backgroundPlaybackEnabled = true
494
525
  nowPlayingMetadata.merge(metadata) { _, new in new }
495
- sessionManager.enableBackgroundPlayback(with: lastConfig)
526
+ _ = sessionManager.enableBackgroundPlayback(with: lastConfig)
496
527
  configureRemoteCommandsIfNeeded()
497
528
  updateNowPlayingInfoInternal()
498
529
  }
@@ -513,7 +544,7 @@ final class NativeAudioEngine {
513
544
  nowPlayingMetadata.removeAll()
514
545
  removeRemoteCommands()
515
546
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
516
- sessionManager.configure(with: lastConfig)
547
+ _ = sessionManager.configure(with: lastConfig)
517
548
  }
518
549
  }
519
550
 
@@ -532,10 +563,14 @@ final class NativeAudioEngine {
532
563
  }
533
564
  let config = lastConfig
534
565
  if backgroundPlaybackEnabled {
535
- sessionManager.enableBackgroundPlayback(with: config)
566
+ _ = sessionManager.enableBackgroundPlayback(with: config)
536
567
  } else {
537
- sessionManager.configure(with: config)
568
+ _ = sessionManager.configure(with: config)
538
569
  }
570
+ _ = ensureRecordingMixerConnected(
571
+ sampleRate: AVAudioSession.sharedInstance().sampleRate,
572
+ channels: AVAudioSession.sharedInstance().inputNumberOfChannels
573
+ )
539
574
  engine.mainMixerNode.outputVolume = Float(masterVolume)
540
575
  engine.prepare()
541
576
  isInitialized = true
@@ -681,11 +716,67 @@ final class NativeAudioEngine {
681
716
 
682
717
  /// Stops the input tap and clears recording state.
683
718
  private func stopRecordingInternal() {
684
- engine.inputNode.removeTap(onBus: 0)
685
719
  recordingState = nil
686
720
  isRecordingFlag = false
687
721
  }
688
722
 
723
+ private func ensureRecordingMixerConnected(sampleRate: Double, channels: Int) -> Bool {
724
+ if recordingMixerConnected {
725
+ return true
726
+ }
727
+ if engine.isRunning {
728
+ lastRecordingError = "recording mixer unavailable while engine is running"
729
+ return false
730
+ }
731
+
732
+ let resolvedRate = sampleRate > 0 ? sampleRate : (lastConfig.sampleRate ?? 44100)
733
+ let resolvedChannels = channels > 0 ? channels : 1
734
+ guard let format = AVAudioFormat(
735
+ standardFormatWithSampleRate: resolvedRate,
736
+ channels: AVAudioChannelCount(resolvedChannels)
737
+ ) else {
738
+ lastRecordingError = "recording format invalid (sampleRate=\(resolvedRate), channels=\(resolvedChannels))"
739
+ return false
740
+ }
741
+
742
+ engine.attach(recordingMixer)
743
+ engine.connect(engine.inputNode, to: recordingMixer, format: format)
744
+ engine.connect(recordingMixer, to: engine.mainMixerNode, format: format)
745
+ recordingMixer.volume = 0.0
746
+ if !recordingTapInstalled {
747
+ recordingMixer.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
748
+ guard let self = self else { return }
749
+ self.queue.async {
750
+ guard let state = self.recordingState else { return }
751
+ self.applyRecordingGain(buffer: buffer)
752
+ self.updateInputLevel(buffer: buffer)
753
+ do {
754
+ try state.file.write(from: buffer)
755
+ } catch {
756
+ return
757
+ }
758
+ }
759
+ }
760
+ recordingTapInstalled = true
761
+ }
762
+ recordingMixerConnected = true
763
+ return true
764
+ }
765
+
766
+ private func detachRecordingMixer() {
767
+ if !recordingMixerConnected {
768
+ return
769
+ }
770
+ if recordingTapInstalled {
771
+ recordingMixer.removeTap(onBus: 0)
772
+ recordingTapInstalled = false
773
+ }
774
+ engine.disconnectNodeInput(recordingMixer)
775
+ engine.disconnectNodeOutput(recordingMixer)
776
+ engine.detach(recordingMixer)
777
+ recordingMixerConnected = false
778
+ }
779
+
689
780
  /// Maps requested output formats to supported formats.
690
781
  private func resolveRecordingFormat(requestedFormat: String) -> (format: String, fileExtension: String) {
691
782
  if requestedFormat == "wav" {
@@ -730,6 +821,19 @@ final class NativeAudioEngine {
730
821
  return baseURL.appendingPathComponent(filename)
731
822
  }
732
823
 
824
+ private func selectPreferredInput(_ session: AVAudioSession) {
825
+ guard let inputs = session.availableInputs, !inputs.isEmpty else { return }
826
+ if let builtIn = inputs.first(where: { $0.portType == .builtInMic }) {
827
+ try? session.setPreferredInput(builtIn)
828
+ }
829
+ }
830
+
831
+ private func describeRoute(_ session: AVAudioSession) -> String {
832
+ let inputs = session.currentRoute.inputs.map { "\($0.portType.rawValue):\($0.portName)" }
833
+ let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue):\($0.portName)" }
834
+ return "inputs[\(inputs.joined(separator: ","))], outputs[\(outputs.joined(separator: ","))]"
835
+ }
836
+
733
837
  /// Returns file size for output metadata.
734
838
  private func resolveFileSize(url: URL) -> Int {
735
839
  let path = url.path
@@ -994,12 +1098,15 @@ final class NativeAudioEngine {
994
1098
  @discardableResult
995
1099
  private func startEngineIfNeeded() -> Bool {
996
1100
  if !isInitialized {
1101
+ lastEngineError = "audio engine not initialized"
997
1102
  return false
998
1103
  }
999
1104
  if !engine.isRunning {
1000
1105
  do {
1001
1106
  try engine.start()
1107
+ lastEngineError = nil
1002
1108
  } catch {
1109
+ lastEngineError = error.localizedDescription
1003
1110
  return false
1004
1111
  }
1005
1112
  }
@@ -98,11 +98,12 @@ public class ExpoAudioEngineModule: Module {
98
98
  AsyncFunction("startRecording") { (config: [String: Any]?) throws in
99
99
  let didStart = engine.startRecording(config: config)
100
100
  if !didStart {
101
+ let detail = engine.getLastRecordingError() ?? "audio input format is invalid or the audio session is not ready"
101
102
  throw NSError(
102
103
  domain: "ExpoAudioEngine",
103
104
  code: 1,
104
105
  userInfo: [
105
- NSLocalizedDescriptionKey: "Failed to start recording. Audio input format is invalid or the audio session is not ready."
106
+ NSLocalizedDescriptionKey: "Failed to start recording. \(detail)."
106
107
  ]
107
108
  )
108
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sezo-audio-engine",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Cross-platform Expo module for the Sezo Audio Engine with iOS implementation and background playback.",
5
5
  "license": "MIT",
6
6
  "author": "Sezo",