sezo-audio-engine 0.0.3 → 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.
package/README.md CHANGED
@@ -22,6 +22,28 @@ Android recording:
22
22
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
23
23
  ```
24
24
 
25
+ ## Android Engine Dependency
26
+
27
+ The Expo module links to the Android engine from JitPack by default. Add the JitPack repository to your app:
28
+
29
+ ```gradle
30
+ // android/build.gradle
31
+ allprojects {
32
+ repositories {
33
+ maven { url "https://www.jitpack.io" }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Optionally pin the engine version in `android/gradle.properties`:
39
+
40
+ ```properties
41
+ sezoAudioEngineVersion=android-engine-v0.1.3
42
+ ```
43
+
44
+ If you want to build from source instead, include the local engine module in
45
+ `android/settings.gradle` and it will be picked up automatically.
46
+
25
47
  iOS recording or background playback:
26
48
 
27
49
  ```json
@@ -44,7 +44,7 @@ dependencies {
44
44
  if (androidEngineProject != null) {
45
45
  implementation androidEngineProject
46
46
  } else {
47
- def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "v0.1.3"
47
+ def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.3"
48
48
  implementation "com.github.Sepzie:SezoAudioEngine:${sezoAudioEngineVersion}"
49
49
  }
50
50
  }
@@ -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
@@ -290,16 +300,51 @@ final class NativeAudioEngine {
290
300
  }
291
301
 
292
302
  /// Starts recording from the input node into AAC or WAV.
293
- func startRecording(config: [String: Any]?) {
294
- queue.sync {
295
- guard !isRecordingFlag else { return }
303
+ @discardableResult
304
+ func startRecording(config: [String: Any]?) -> Bool {
305
+ return queue.sync {
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
+ }
296
328
  ensureInitializedIfNeeded()
297
329
 
298
- let inputNode = engine.inputNode
299
- let inputFormat = inputNode.outputFormat(forBus: 0)
330
+ guard startEngineIfNeeded() else {
331
+ lastRecordingError = lastEngineError ?? "audio engine failed to start"
332
+ return false
333
+ }
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))"
345
+ return false
346
+ }
300
347
  let requestedFormat = config?["format"] as? String ?? "aac"
301
- let channels = config?["channels"] as? Int ?? Int(inputFormat.channelCount)
302
- let sampleRate = config?["sampleRate"] as? Double ?? inputFormat.sampleRate
303
348
  let bitrate = resolveBitrate(config: config)
304
349
  let formatInfo = resolveRecordingFormat(requestedFormat: requestedFormat)
305
350
  let outputURL = resolveOutputURL(
@@ -339,33 +384,28 @@ final class NativeAudioEngine {
339
384
  startTimeMs: startTimeMs
340
385
  )
341
386
 
342
- guard startEngineIfNeeded() else {
343
- recordingState = nil
344
- return
345
- }
346
-
347
- inputNode.removeTap(onBus: 0)
348
- inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
349
- guard let self = self else { return }
350
- self.queue.async {
351
- guard let state = self.recordingState else { return }
352
- self.applyRecordingGain(buffer: buffer)
353
- self.updateInputLevel(buffer: buffer)
354
- do {
355
- try state.file.write(from: buffer)
356
- } catch {
357
- return
358
- }
359
- }
360
- }
361
-
362
387
  isRecordingFlag = true
388
+ return true
363
389
  } catch {
390
+ lastRecordingError = "recording setup failed: \(error.localizedDescription)"
364
391
  recordingState = nil
392
+ return false
365
393
  }
366
394
  }
367
395
  }
368
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
+
369
409
  /// Stops recording and returns metadata about the output file.
370
410
  func stopRecording() -> [String: Any] {
371
411
  return queue.sync {
@@ -411,6 +451,10 @@ final class NativeAudioEngine {
411
451
  }
412
452
  }
413
453
 
454
+ func getLastRecordingError() -> String? {
455
+ return queue.sync { lastRecordingError }
456
+ }
457
+
414
458
  /// Offline export for a single track.
415
459
  func extractTrack(trackId: String, config: [String: Any]?) -> [String: Any] {
416
460
  return queue.sync {
@@ -479,7 +523,7 @@ final class NativeAudioEngine {
479
523
  queue.sync {
480
524
  backgroundPlaybackEnabled = true
481
525
  nowPlayingMetadata.merge(metadata) { _, new in new }
482
- sessionManager.enableBackgroundPlayback(with: lastConfig)
526
+ _ = sessionManager.enableBackgroundPlayback(with: lastConfig)
483
527
  configureRemoteCommandsIfNeeded()
484
528
  updateNowPlayingInfoInternal()
485
529
  }
@@ -500,7 +544,7 @@ final class NativeAudioEngine {
500
544
  nowPlayingMetadata.removeAll()
501
545
  removeRemoteCommands()
502
546
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
503
- sessionManager.configure(with: lastConfig)
547
+ _ = sessionManager.configure(with: lastConfig)
504
548
  }
505
549
  }
506
550
 
@@ -519,10 +563,14 @@ final class NativeAudioEngine {
519
563
  }
520
564
  let config = lastConfig
521
565
  if backgroundPlaybackEnabled {
522
- sessionManager.enableBackgroundPlayback(with: config)
566
+ _ = sessionManager.enableBackgroundPlayback(with: config)
523
567
  } else {
524
- sessionManager.configure(with: config)
568
+ _ = sessionManager.configure(with: config)
525
569
  }
570
+ _ = ensureRecordingMixerConnected(
571
+ sampleRate: AVAudioSession.sharedInstance().sampleRate,
572
+ channels: AVAudioSession.sharedInstance().inputNumberOfChannels
573
+ )
526
574
  engine.mainMixerNode.outputVolume = Float(masterVolume)
527
575
  engine.prepare()
528
576
  isInitialized = true
@@ -668,11 +716,67 @@ final class NativeAudioEngine {
668
716
 
669
717
  /// Stops the input tap and clears recording state.
670
718
  private func stopRecordingInternal() {
671
- engine.inputNode.removeTap(onBus: 0)
672
719
  recordingState = nil
673
720
  isRecordingFlag = false
674
721
  }
675
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
+
676
780
  /// Maps requested output formats to supported formats.
677
781
  private func resolveRecordingFormat(requestedFormat: String) -> (format: String, fileExtension: String) {
678
782
  if requestedFormat == "wav" {
@@ -717,6 +821,19 @@ final class NativeAudioEngine {
717
821
  return baseURL.appendingPathComponent(filename)
718
822
  }
719
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
+
720
837
  /// Returns file size for output metadata.
721
838
  private func resolveFileSize(url: URL) -> Int {
722
839
  let path = url.path
@@ -981,12 +1098,15 @@ final class NativeAudioEngine {
981
1098
  @discardableResult
982
1099
  private func startEngineIfNeeded() -> Bool {
983
1100
  if !isInitialized {
1101
+ lastEngineError = "audio engine not initialized"
984
1102
  return false
985
1103
  }
986
1104
  if !engine.isRunning {
987
1105
  do {
988
1106
  try engine.start()
1107
+ lastEngineError = nil
989
1108
  } catch {
1109
+ lastEngineError = error.localizedDescription
990
1110
  return false
991
1111
  }
992
1112
  }
@@ -95,8 +95,18 @@ public class ExpoAudioEngineModule: Module {
95
95
  engine.setTempoAndPitch(tempo: tempo, pitch: pitch)
96
96
  }
97
97
 
98
- AsyncFunction("startRecording") { (config: [String: Any]?) in
99
- engine.startRecording(config: config)
98
+ AsyncFunction("startRecording") { (config: [String: Any]?) throws in
99
+ let didStart = engine.startRecording(config: config)
100
+ if !didStart {
101
+ let detail = engine.getLastRecordingError() ?? "audio input format is invalid or the audio session is not ready"
102
+ throw NSError(
103
+ domain: "ExpoAudioEngine",
104
+ code: 1,
105
+ userInfo: [
106
+ NSLocalizedDescriptionKey: "Failed to start recording. \(detail)."
107
+ ]
108
+ )
109
+ }
100
110
  }
101
111
  AsyncFunction("stopRecording") {
102
112
  return engine.stopRecording()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sezo-audio-engine",
3
- "version": "0.0.3",
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",