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
|
-
|
|
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
|
-
|
|
17
|
+
lastError = "setCategory failed: \(error.localizedDescription)"
|
|
18
|
+
return false
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
if let sampleRate = config.sampleRate {
|
|
18
|
-
|
|
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
|
-
|
|
33
|
+
do {
|
|
34
|
+
try session.setPreferredIOBufferDuration(duration)
|
|
35
|
+
} catch {
|
|
36
|
+
lastError = "setPreferredIOBufferDuration failed: \(error.localizedDescription)"
|
|
37
|
+
}
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
lastError = "setCategory failed: \(error.localizedDescription)"
|
|
61
|
+
return false
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
if let sampleRate = config.sampleRate {
|
|
44
|
-
|
|
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
|
-
|
|
76
|
+
do {
|
|
77
|
+
try session.setPreferredIOBufferDuration(duration)
|
|
78
|
+
} catch {
|
|
79
|
+
lastError = "setPreferredIOBufferDuration failed: \(error.localizedDescription)"
|
|
80
|
+
}
|
|
52
81
|
}
|
|
53
82
|
}
|
|
54
83
|
|
|
55
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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.
|
|
106
|
+
NSLocalizedDescriptionKey: "Failed to start recording. \(detail)."
|
|
106
107
|
]
|
|
107
108
|
)
|
|
108
109
|
}
|