@siteed/expo-audio-studio 2.6.3 → 2.8.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 +12 -1
- package/app.plugin.cjs +2 -0
- package/ios/AudioNotificationManager.swift +42 -19
- package/ios/AudioProcessingHelpers.swift +5 -5
- package/ios/AudioProcessor.swift +52 -217
- package/ios/AudioStreamManager.swift +136 -41
- package/ios/DataPoint.swift +5 -5
- package/ios/ExpoAudioStreamModule.swift +2 -1
- package/package.json +6 -6
- package/plugin/build/index.cjs +192 -0
- package/plugin/build/index.js +6 -5
- package/plugin/src/index.ts +8 -5
- package/app.plugin.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
## [2.8.0] - 2025-05-04
|
|
12
|
+
### Changed
|
|
13
|
+
- feat(playground): Version 1.0.1 with Audio Enhancements, App Updates, and Navigation Refactor (#229) ([868fca0](https://github.com/deeeed/expo-audio-stream/commit/868fca026119aea116a22670c2b6fe364b6df06c))
|
|
14
|
+
- chore: enhance publish script to include git push after documentation updates ([1b0b0db](https://github.com/deeeed/expo-audio-stream/commit/1b0b0db6cf40a6397e6d7438cb7543c93e67b143))
|
|
15
|
+
- chore(expo-audio-studio): release @siteed/expo-audio-studio@2.7.0 ([fe19a2f](https://github.com/deeeed/expo-audio-stream/commit/fe19a2fa1af6033cfa025691f25a0e9bcd64b37c))
|
|
16
|
+
## [2.7.0] - 2025-05-04
|
|
17
|
+
### Changed
|
|
18
|
+
- fix: Enhance iOS Background Audio Recording and Audio Format Conversion (#228) ([c17169b](https://github.com/deeeed/expo-audio-stream/commit/c17169bf9275706abf287712acc30df2f1814ed7))
|
|
19
|
+
- chore(expo-audio-studio): improve build script for cjs esm conversion ([767dfbe](https://github.com/deeeed/expo-audio-stream/commit/767dfbe5da0f1550b689f6859e2e5fccf7f8141c))
|
|
11
20
|
## [2.6.3] - 2025-05-03
|
|
12
21
|
### Changed
|
|
13
22
|
- chore: update readme with store download information (#224) ([c404d86](https://github.com/deeeed/expo-audio-stream/commit/c404d860cdb1c4c4bbc3767214f56bf547acec33))
|
|
@@ -213,7 +222,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
213
222
|
- Feature: Audio features extraction during recording.
|
|
214
223
|
- Feature: Consistent WAV PCM recording format across all platforms.
|
|
215
224
|
|
|
216
|
-
[unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.
|
|
225
|
+
[unreleased]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.8.0...HEAD
|
|
226
|
+
[2.8.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.7.0...@siteed/expo-audio-studio@2.8.0
|
|
227
|
+
[2.7.0]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.6.3...@siteed/expo-audio-studio@2.7.0
|
|
217
228
|
[2.6.3]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.6.2...@siteed/expo-audio-studio@2.6.3
|
|
218
229
|
[2.6.2]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.6.1...@siteed/expo-audio-studio@2.6.2
|
|
219
230
|
[2.6.1]: https://github.com/deeeed/expo-audio-stream/compare/@siteed/expo-audio-studio@2.6.0...@siteed/expo-audio-studio@2.6.1
|
package/app.plugin.cjs
ADDED
|
@@ -37,43 +37,66 @@ class AudioNotificationManager {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
func showInitialNotification() {
|
|
40
|
-
|
|
40
|
+
// Wrap notification generation in a main thread dispatch
|
|
41
|
+
DispatchQueue.main.async { [weak self] in
|
|
42
|
+
guard let self = self else { return }
|
|
43
|
+
|
|
44
|
+
// No need for try-catch as this method doesn't throw
|
|
45
|
+
self.updateNotification()
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
func startUpdates(startTime: Date) {
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// Cancel any existing timer first
|
|
51
|
+
stopUpdates()
|
|
52
|
+
|
|
53
|
+
// Create a new timer on the main thread
|
|
54
|
+
DispatchQueue.main.async { [weak self] in
|
|
55
|
+
guard let self = self else { return }
|
|
46
56
|
|
|
47
57
|
self.updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
48
58
|
guard let self = self else { return }
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if now.timeIntervalSince(self.lastUpdateTime) >= self.minUpdateInterval {
|
|
52
|
-
self.updateNotification()
|
|
53
|
-
self.lastUpdateTime = now
|
|
54
|
-
}
|
|
59
|
+
self.currentDuration = Date().timeIntervalSince(startTime)
|
|
60
|
+
self.updateState(isPaused: false)
|
|
55
61
|
}
|
|
56
|
-
RunLoop.main.add(self.updateTimer!, forMode: .common)
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
// Run the timer even when scrolling
|
|
64
|
+
self.updateTimer?.tolerance = 0.1
|
|
65
|
+
RunLoop.current.add(self.updateTimer!, forMode: .common)
|
|
66
|
+
|
|
67
|
+
// Update notification immediately
|
|
68
|
+
self.updateState(isPaused: false)
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
func stopUpdates() {
|
|
63
|
-
|
|
73
|
+
// Always execute timer invalidation on main thread
|
|
74
|
+
DispatchQueue.main.async { [weak self] in
|
|
75
|
+
guard let self = self else { return }
|
|
76
|
+
|
|
64
77
|
self.updateTimer?.invalidate()
|
|
65
78
|
self.updateTimer = nil
|
|
66
79
|
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
// Clean up notification
|
|
81
|
+
do {
|
|
82
|
+
self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [self.notificationId])
|
|
83
|
+
self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [self.notificationId])
|
|
84
|
+
} catch {
|
|
85
|
+
Logger.debug("AudioNotificationManager", "Error removing notifications: \(error)")
|
|
86
|
+
}
|
|
69
87
|
}
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
func updateState(isPaused: Bool) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
DispatchQueue.main.async { [weak self] in
|
|
92
|
+
guard let self = self else { return }
|
|
93
|
+
|
|
94
|
+
let now = Date()
|
|
95
|
+
if now.timeIntervalSince(self.lastUpdateTime) >= self.minUpdateInterval {
|
|
96
|
+
// No need for try-catch as this method doesn't throw
|
|
97
|
+
self.updateNotification(forcePauseState: isPaused)
|
|
98
|
+
self.lastUpdateTime = now
|
|
99
|
+
}
|
|
77
100
|
}
|
|
78
101
|
}
|
|
79
102
|
|
|
@@ -92,7 +115,7 @@ class AudioNotificationManager {
|
|
|
92
115
|
guard let self = self else { return }
|
|
93
116
|
|
|
94
117
|
// If we have a notification and it was recently updated, skip
|
|
95
|
-
if let
|
|
118
|
+
if let _ = notifications.first(where: { $0.request.identifier == self.notificationId }),
|
|
96
119
|
Date().timeIntervalSince(self.lastUpdateTime) < self.minUpdateInterval {
|
|
97
120
|
return
|
|
98
121
|
}
|
|
@@ -337,7 +337,6 @@ func computeMelSpectrogram(from segment: [Float], sampleRate: Float) -> [Float]
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
func computeSpectralContrast(from segment: [Float], sampleRate: Float) -> [Float] {
|
|
340
|
-
let nBands = 7
|
|
341
340
|
let fftData = sharedFFT.processSegment(segment)
|
|
342
341
|
|
|
343
342
|
let magnitudeSpectrum = computeMagnitudeSpectrum(from: fftData)
|
|
@@ -425,7 +424,7 @@ func loadAudioFile(_ fileUri: String) throws -> AudioData {
|
|
|
425
424
|
let frameCount = UInt32(file.length)
|
|
426
425
|
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)!
|
|
427
426
|
|
|
428
|
-
try file.read(into: buffer)
|
|
427
|
+
try file.read(into: buffer, frameCount: frameCount)
|
|
429
428
|
|
|
430
429
|
// Convert buffer to float array
|
|
431
430
|
let samples: [Float]
|
|
@@ -490,8 +489,8 @@ func computeFeatures(segmentData: [Float], sampleRate: Float, sumSquares: Float,
|
|
|
490
489
|
let zcr = featureOptions["zcr"] == true ? Float(zeroCrossings) / Float(segmentLength) : 0
|
|
491
490
|
|
|
492
491
|
// Compute min and max amplitudes
|
|
493
|
-
let
|
|
494
|
-
let
|
|
492
|
+
let _ = segmentData.min() ?? 0
|
|
493
|
+
let _ = segmentData.max() ?? 0
|
|
495
494
|
|
|
496
495
|
// Call feature extraction functions
|
|
497
496
|
let mfcc = featureOptions["mfcc"] == true ? extractMFCC(from: segmentData, sampleRate: sampleRate) : []
|
|
@@ -598,12 +597,13 @@ func extractRawAudioData(
|
|
|
598
597
|
finalBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCount)!
|
|
599
598
|
|
|
600
599
|
var error: NSError?
|
|
601
|
-
|
|
600
|
+
_ = converter.convert(to: finalBuffer, error: &error) { inNumPackets, outStatus in
|
|
602
601
|
outStatus.pointee = .haveData
|
|
603
602
|
return buffer
|
|
604
603
|
}
|
|
605
604
|
|
|
606
605
|
if let error = error {
|
|
606
|
+
Logger.debug("AudioProcessingHelpers", "Format conversion failed: \(error.localizedDescription)")
|
|
607
607
|
throw error
|
|
608
608
|
}
|
|
609
609
|
} else {
|
package/ios/AudioProcessor.swift
CHANGED
|
@@ -142,7 +142,7 @@ public class AudioProcessor {
|
|
|
142
142
|
|
|
143
143
|
let totalFrameCount = AVAudioFrameCount(audioFile.length)
|
|
144
144
|
var framesPerBuffer: AVAudioFrameCount
|
|
145
|
-
let
|
|
145
|
+
let _: Int // Changed from actualPointsPerSecond
|
|
146
146
|
|
|
147
147
|
NSLog("""
|
|
148
148
|
[AudioProcessor] Starting audio processing:
|
|
@@ -215,7 +215,7 @@ public class AudioProcessor {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
channelCount = Int(audioFile.processingFormat.channelCount)
|
|
218
|
-
|
|
218
|
+
let _ = Array(repeating: [Float](repeating: 0, count: Int(framesPerBuffer)), count: channelCount) // Changed from var data
|
|
219
219
|
|
|
220
220
|
var channelData = [Float]()
|
|
221
221
|
while startFrame < endFrame {
|
|
@@ -517,7 +517,7 @@ public class AudioProcessor {
|
|
|
517
517
|
|
|
518
518
|
let startTime = CACurrentMediaTime()
|
|
519
519
|
let sampleRate = Float(audioFile.fileFormat.sampleRate)
|
|
520
|
-
let
|
|
520
|
+
let _ = AVAudioFrameCount(audioFile.length) // Changed from totalFrameCount
|
|
521
521
|
let bitDepth = audioFile.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int ?? 16
|
|
522
522
|
let numberOfChannels = Int(audioFile.fileFormat.channelCount)
|
|
523
523
|
|
|
@@ -595,7 +595,7 @@ public class AudioProcessor {
|
|
|
595
595
|
dB: Float(20 * log10(Double(rms))), // Use RMS for dB calculation
|
|
596
596
|
silent: rms < SILENCE_THRESHOLD_RMS, // Use RMS for silence detection
|
|
597
597
|
features: computeFeatures(
|
|
598
|
-
segmentData: Array(
|
|
598
|
+
segmentData: Array(summedData[0..<Int(framesToRead)]), // Fixed dangling pointer
|
|
599
599
|
sampleRate: sampleRate,
|
|
600
600
|
sumSquares: rms * rms,
|
|
601
601
|
zeroCrossings: 0,
|
|
@@ -692,7 +692,7 @@ public class AudioProcessor {
|
|
|
692
692
|
|
|
693
693
|
// Output format setup
|
|
694
694
|
let requestedFormat = outputFormat?["format"] as? String ?? "wav"
|
|
695
|
-
let validFormats = ["wav", "aac"
|
|
695
|
+
let validFormats = ["wav", "aac"]
|
|
696
696
|
let formatStr = validFormats.contains(requestedFormat.lowercased()) ? requestedFormat.lowercased() : "aac"
|
|
697
697
|
|
|
698
698
|
if formatStr != requestedFormat.lowercased() {
|
|
@@ -704,7 +704,7 @@ public class AudioProcessor {
|
|
|
704
704
|
let targetBitDepth = outputFormat?["bitDepth"] as? Int ?? 16
|
|
705
705
|
let bitrate = outputFormat?["bitrate"] as? Int ?? 128000
|
|
706
706
|
|
|
707
|
-
let fileExtension = formatStr == "wav" ? "wav" :
|
|
707
|
+
let fileExtension = formatStr == "wav" ? "wav" : "aac"
|
|
708
708
|
let outputURL = FileManager.default.temporaryDirectory
|
|
709
709
|
.appendingPathComponent(outputFileName ?? UUID().uuidString)
|
|
710
710
|
.appendingPathExtension(fileExtension)
|
|
@@ -753,7 +753,7 @@ public class AudioProcessor {
|
|
|
753
753
|
Logger.debug("AudioProcessor", "Trim operation completed")
|
|
754
754
|
Logger.debug("AudioProcessor", "- Output file: \(outputURL.path)")
|
|
755
755
|
Logger.debug("AudioProcessor", "- File exists: \(FileManager.default.fileExists(atPath: outputURL.path))")
|
|
756
|
-
Logger.debug("AudioProcessor", "- File size: \(try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? Int64 ?? 0) bytes")
|
|
756
|
+
Logger.debug("AudioProcessor", "- File size: \((try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? Int64) ?? 0) bytes") // Fixed optional unwrapping
|
|
757
757
|
Logger.debug("AudioProcessor", "- File extension: \(outputURL.pathExtension)")
|
|
758
758
|
|
|
759
759
|
return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(inputSampleRate), channels: inputChannels, bitDepth: 16, bitrate: bitrate)
|
|
@@ -801,7 +801,16 @@ public class AudioProcessor {
|
|
|
801
801
|
try audioFile.read(into: buffer, frameCount: frameCount)
|
|
802
802
|
let converter = AVAudioConverter(from: inputFormat, to: targetFormat)!
|
|
803
803
|
let convertedBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCount)!
|
|
804
|
-
|
|
804
|
+
var error: NSError?
|
|
805
|
+
_ = converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in
|
|
806
|
+
outStatus.pointee = .haveData
|
|
807
|
+
return buffer
|
|
808
|
+
}
|
|
809
|
+
if let error = error {
|
|
810
|
+
Logger.debug("AudioProcessor", "Format conversion failed: \(error.localizedDescription)")
|
|
811
|
+
Logger.debug("AudioProcessor", "Skipping this buffer")
|
|
812
|
+
continue
|
|
813
|
+
}
|
|
805
814
|
try outputFile.write(from: convertedBuffer)
|
|
806
815
|
cumulativeFrames += Int64(frameCount)
|
|
807
816
|
let progress = Float(cumulativeFrames) / Float(totalFrames) * 100
|
|
@@ -809,227 +818,53 @@ public class AudioProcessor {
|
|
|
809
818
|
}
|
|
810
819
|
return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(targetSampleRate), channels: targetChannels, bitDepth: targetBitDepth, bitrate: bitrate)
|
|
811
820
|
} else {
|
|
812
|
-
// AAC
|
|
813
|
-
|
|
814
|
-
let fileType: AVFileType
|
|
815
|
-
|
|
816
|
-
if formatStr == "aac" {
|
|
817
|
-
// AAC settings
|
|
818
|
-
let outputExtension = "m4a"
|
|
819
|
-
let tempOutputURL = FileManager.default.temporaryDirectory
|
|
820
|
-
.appendingPathComponent(outputFileName ?? UUID().uuidString)
|
|
821
|
-
.appendingPathExtension(outputExtension)
|
|
822
|
-
|
|
823
|
-
// Validate and adjust sample rate for AAC
|
|
824
|
-
// AAC typically supports: 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000 Hz
|
|
825
|
-
let supportedSampleRates = [8000.0, 11025.0, 12000.0, 16000.0, 22050.0, 24000.0, 32000.0, 44100.0, 48000.0]
|
|
826
|
-
|
|
827
|
-
// Default to 44100 if not specified
|
|
828
|
-
var sampleRate = outputFormat?["sampleRate"] as? Double ?? 44100.0
|
|
829
|
-
|
|
830
|
-
// Find closest supported sample rate
|
|
831
|
-
if !supportedSampleRates.contains(sampleRate) {
|
|
832
|
-
let closestRate = supportedSampleRates.min(by: { abs($0 - sampleRate) < abs($1 - sampleRate) }) ?? 44100.0
|
|
833
|
-
Logger.debug("AudioProcessor", "Unsupported sample rate \(sampleRate)Hz for AAC, using closest supported rate: \(closestRate)Hz")
|
|
834
|
-
sampleRate = closestRate
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Validate channels (AAC typically supports 1 or 2 channels)
|
|
838
|
-
var channels = outputFormat?["channels"] as? Int ?? 2
|
|
839
|
-
if channels > 2 {
|
|
840
|
-
Logger.debug("AudioProcessor", "AAC encoding doesn't support \(channels) channels, limiting to 2 channels")
|
|
841
|
-
channels = 2
|
|
842
|
-
} else if channels < 1 {
|
|
843
|
-
channels = 1
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// Validate bitrate (AAC typically supports 8000-320000 bps)
|
|
847
|
-
var bitrate = outputFormat?["bitrate"] as? Int ?? 128000
|
|
848
|
-
if bitrate < 8000 {
|
|
849
|
-
Logger.debug("AudioProcessor", "AAC bitrate too low, setting to minimum 8000 bps")
|
|
850
|
-
bitrate = 8000
|
|
851
|
-
} else if bitrate > 320000 {
|
|
852
|
-
Logger.debug("AudioProcessor", "AAC bitrate too high, setting to maximum 320000 bps")
|
|
853
|
-
bitrate = 320000
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Set up proper audio settings for AAC
|
|
857
|
-
outputSettings = [
|
|
858
|
-
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
859
|
-
AVSampleRateKey: sampleRate,
|
|
860
|
-
AVNumberOfChannelsKey: channels,
|
|
861
|
-
AVEncoderBitRateKey: bitrate,
|
|
862
|
-
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
863
|
-
]
|
|
864
|
-
fileType = .m4a
|
|
865
|
-
|
|
866
|
-
Logger.debug("AudioProcessor", """
|
|
867
|
-
Configuring AAC output:
|
|
868
|
-
- Container: m4a
|
|
869
|
-
- Format: AAC
|
|
870
|
-
- Sample rate: \(sampleRate)Hz
|
|
871
|
-
- Channels: \(channels)
|
|
872
|
-
- Bitrate: \(bitrate) bps
|
|
873
|
-
- Output path: \(tempOutputURL.path)
|
|
874
|
-
- File type: \(fileType)
|
|
875
|
-
""")
|
|
876
|
-
} else {
|
|
877
|
-
// Opus settings - use CAF container which can hold Opus
|
|
878
|
-
outputSettings = [
|
|
879
|
-
AVFormatIDKey: kAudioFormatOpus,
|
|
880
|
-
AVSampleRateKey: targetSampleRate,
|
|
881
|
-
AVNumberOfChannelsKey: targetChannels,
|
|
882
|
-
AVEncoderBitRateKey: bitrate
|
|
883
|
-
]
|
|
884
|
-
fileType = .caf // Core Audio Format can contain Opus
|
|
885
|
-
}
|
|
821
|
+
// Use AAC instead of Opus (Opus support removed)
|
|
822
|
+
Logger.debug("AudioProcessor", "Using AAC format instead of requested \(formatStr)")
|
|
886
823
|
|
|
887
|
-
//
|
|
888
|
-
let
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
fileType: fileType
|
|
897
|
-
)
|
|
824
|
+
// Keep the existing AAC settings structure for consistency
|
|
825
|
+
let outputSettings: [String: Any] = [
|
|
826
|
+
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
827
|
+
AVSampleRateKey: targetSampleRate,
|
|
828
|
+
AVNumberOfChannelsKey: targetChannels,
|
|
829
|
+
AVEncoderBitRateKey: bitrate,
|
|
830
|
+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
831
|
+
]
|
|
832
|
+
let _ = AVFileType.m4a // Changed from fileType
|
|
898
833
|
|
|
899
|
-
//
|
|
900
|
-
let
|
|
901
|
-
writerInput.expectsMediaDataInRealTime = false
|
|
902
|
-
assetWriter.add(writerInput)
|
|
834
|
+
// 4. Update container extension logic for when Opus was selected
|
|
835
|
+
let _ = "m4a" // Changed from tempFileExtension
|
|
903
836
|
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
assetWriter.startSession(atSourceTime: CMTime.zero)
|
|
837
|
+
// 5. Update the MIME type logic for AAC only
|
|
838
|
+
let _ = "audio/mp4" // Changed from mimeType
|
|
907
839
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
let pcmBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(bufferSize))!
|
|
911
|
-
|
|
840
|
+
let outputFile = try AVAudioFile(forWriting: outputURL, settings: outputSettings)
|
|
841
|
+
var totalFrames: Int64 = 0
|
|
912
842
|
for range in keepRanges {
|
|
843
|
+
// Break down complex expressions
|
|
913
844
|
let startTimeInSeconds = range[0] / 1000
|
|
914
845
|
let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate)
|
|
915
846
|
|
|
916
847
|
let endTimeInSeconds = range[1] / 1000
|
|
917
848
|
let endFramePosition = endTimeInSeconds * inputSampleRate
|
|
918
|
-
let
|
|
849
|
+
let frameCount = AVAudioFrameCount(endFramePosition - Double(startFrame))
|
|
919
850
|
|
|
920
|
-
|
|
921
|
-
var framesProcessed: AVAudioFrameCount = 0
|
|
851
|
+
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount)!
|
|
922
852
|
audioFile.framePosition = startFrame
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
do {
|
|
929
|
-
try audioFile.read(into: buffer, frameCount: framesToRead)
|
|
930
|
-
|
|
931
|
-
// Convert the buffer to the target format
|
|
932
|
-
let converter = AVAudioConverter(from: inputFormat, to: targetFormat)!
|
|
933
|
-
let convertedBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: framesToRead)!
|
|
934
|
-
|
|
935
|
-
var error: NSError?
|
|
936
|
-
let conversionStatus = converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in
|
|
937
|
-
outStatus.pointee = .haveData
|
|
938
|
-
return buffer
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
if let error = error {
|
|
942
|
-
Logger.debug("AudioProcessor", "Conversion error: \(error)")
|
|
943
|
-
continue
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// Create a sample buffer and append to writer
|
|
947
|
-
if let sampleBuffer = createSampleBuffer(from: convertedBuffer) {
|
|
948
|
-
// Wait until the writer is ready
|
|
949
|
-
while !writerInput.isReadyForMoreMediaData {
|
|
950
|
-
Thread.sleep(forTimeInterval: 0.01)
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
if !writerInput.append(sampleBuffer) {
|
|
954
|
-
Logger.debug("AudioProcessor", "Failed to append sample buffer: \(assetWriter.error?.localizedDescription ?? "Unknown error")")
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
framesProcessed += framesToRead
|
|
959
|
-
cumulativeFrames += Int64(framesToRead)
|
|
960
|
-
let progress = Float(cumulativeFrames) / Float(totalFrames) * 100
|
|
961
|
-
progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame))
|
|
962
|
-
|
|
963
|
-
if framesProcessed % 10000 == 0 { // Log every 10000 frames to avoid excessive logging
|
|
964
|
-
Logger.debug("AudioProcessor", "Processed \(framesProcessed)/\(totalFramesToProcess) frames")
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
} catch {
|
|
968
|
-
Logger.debug("AudioProcessor", "Error reading audio: \(error)")
|
|
969
|
-
break
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// Finish writing properly
|
|
975
|
-
writerInput.markAsFinished()
|
|
976
|
-
let finishSemaphore = DispatchSemaphore(value: 0)
|
|
977
|
-
assetWriter.finishWriting {
|
|
978
|
-
if let error = assetWriter.error {
|
|
979
|
-
Logger.debug("AudioProcessor", "Error finishing writing: \(error)")
|
|
980
|
-
} else {
|
|
981
|
-
Logger.debug("AudioProcessor", "Writing finished successfully")
|
|
982
|
-
|
|
983
|
-
// Verify the output file
|
|
984
|
-
let fileExists = FileManager.default.fileExists(atPath: tempOutputURL.path)
|
|
985
|
-
let fileSize = (try? FileManager.default.attributesOfItem(atPath: tempOutputURL.path)[.size] as? Int64) ?? 0
|
|
986
|
-
|
|
987
|
-
Logger.debug("AudioProcessor", """
|
|
988
|
-
Output file verification:
|
|
989
|
-
- Path: \(tempOutputURL.path)
|
|
990
|
-
- Exists: \(fileExists)
|
|
991
|
-
- Size: \(fileSize) bytes
|
|
992
|
-
- Extension: \(tempOutputURL.pathExtension)
|
|
993
|
-
""")
|
|
994
|
-
}
|
|
995
|
-
finishSemaphore.signal()
|
|
996
|
-
}
|
|
997
|
-
finishSemaphore.wait()
|
|
998
|
-
|
|
999
|
-
// Verify the file was created successfully
|
|
1000
|
-
guard FileManager.default.fileExists(atPath: tempOutputURL.path) else {
|
|
1001
|
-
reject("FILE_CREATION_FAILED", "Failed to create output file")
|
|
1002
|
-
return nil
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
// Create compression info
|
|
1006
|
-
var compressionInfo: [String: Any] = [
|
|
1007
|
-
"format": formatStr,
|
|
1008
|
-
"bitrate": bitrate,
|
|
1009
|
-
"size": (try? FileManager.default.attributesOfItem(atPath: tempOutputURL.path)[.size] as? Int64) ?? 0
|
|
1010
|
-
]
|
|
1011
|
-
|
|
1012
|
-
// Add fallback information if applicable
|
|
1013
|
-
if formatStr != requestedFormat.lowercased() {
|
|
1014
|
-
compressionInfo["requestedFormat"] = requestedFormat
|
|
1015
|
-
compressionInfo["fallbackReason"] = "Unsupported format"
|
|
853
|
+
try audioFile.read(into: buffer, frameCount: frameCount)
|
|
854
|
+
try outputFile.write(from: buffer)
|
|
855
|
+
totalFrames += Int64(frameCount)
|
|
856
|
+
let progress = Float(cumulativeFrames) / Float(totalFrames) * 100
|
|
857
|
+
progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame))
|
|
1016
858
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
sampleRate: Int(targetSampleRate),
|
|
1027
|
-
channels: targetChannels,
|
|
1028
|
-
bitDepth: 16,
|
|
1029
|
-
mimeType: mimeType,
|
|
1030
|
-
requestedFormat: formatStr,
|
|
1031
|
-
actualFormat: tempFileExtension,
|
|
1032
|
-
compression: compressionInfo
|
|
859
|
+
return createTrimResult(
|
|
860
|
+
from: outputURL,
|
|
861
|
+
keepRanges: keepRanges,
|
|
862
|
+
formatStr: formatStr,
|
|
863
|
+
sampleRate: Int(targetSampleRate),
|
|
864
|
+
channels: targetChannels,
|
|
865
|
+
bitDepth: 16,
|
|
866
|
+
bitrate: bitrate,
|
|
867
|
+
compression: nil
|
|
1033
868
|
)
|
|
1034
869
|
}
|
|
1035
870
|
}
|
|
@@ -1068,7 +903,7 @@ public class AudioProcessor {
|
|
|
1068
903
|
private func createTrimResult(from url: URL, keepRanges: [[Double]], formatStr: String, sampleRate: Int, channels: Int, bitDepth: Int, bitrate: Int, compression: [String: Any]? = nil) -> TrimResult {
|
|
1069
904
|
let durationMs = keepRanges.map { $0[1] - $0[0] }.reduce(0, +)
|
|
1070
905
|
let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64 ?? 0) ?? 0
|
|
1071
|
-
let fileExtension = formatStr == "wav" ? "wav" :
|
|
906
|
+
let fileExtension = formatStr == "wav" ? "wav" : "aac"
|
|
1072
907
|
return TrimResult(
|
|
1073
908
|
uri: url.absoluteString,
|
|
1074
909
|
filename: url.lastPathComponent,
|
|
@@ -110,6 +110,9 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
110
110
|
// ---> ADD BACK deviceManager PROPERTY <---
|
|
111
111
|
private let deviceManager = AudioDeviceManager()
|
|
112
112
|
|
|
113
|
+
// Add the stopping flag to the class properties
|
|
114
|
+
private var stopping: Bool = false
|
|
115
|
+
|
|
113
116
|
/// Initializes the AudioStreamManager
|
|
114
117
|
override init() {
|
|
115
118
|
super.init()
|
|
@@ -137,11 +140,25 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
deinit {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// Ensure wake lock is disabled when the manager is deallocated
|
|
144
|
+
disableWakeLock()
|
|
145
|
+
|
|
146
|
+
// Stop any active recording to properly release resources
|
|
147
|
+
if isRecording {
|
|
148
|
+
audioEngine.stop()
|
|
149
|
+
audioEngine.reset()
|
|
144
150
|
}
|
|
151
|
+
|
|
152
|
+
// Remove ALL notification observers properly
|
|
153
|
+
NotificationCenter.default.removeObserver(self)
|
|
154
|
+
|
|
155
|
+
// Clean up notification manager
|
|
156
|
+
notificationManager?.stopUpdates()
|
|
157
|
+
notificationManager = nil
|
|
158
|
+
|
|
159
|
+
// Cleanup media timer
|
|
160
|
+
mediaInfoUpdateTimer?.invalidate()
|
|
161
|
+
mediaInfoUpdateTimer = nil
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
/// Handles an audio session interruption.
|
|
@@ -362,32 +379,68 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
362
379
|
}
|
|
363
380
|
|
|
364
381
|
@objc private func handleAppDidEnterBackground(_ notification: Notification) {
|
|
365
|
-
if
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
// Skip if we're in the process of stopping - this prevents race conditions
|
|
383
|
+
if !isRecording || stopping {
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// If keepAwake is false, we should track this as a pause and actually pause the engine
|
|
388
|
+
if let settings = recordingSettings, !settings.keepAwake {
|
|
389
|
+
Logger.debug("AudioStreamManager", "App entering background with keepAwake=false, pausing recording")
|
|
390
|
+
currentPauseStart = Date()
|
|
391
|
+
// Explicitly pause the engine but don't change isPaused state
|
|
392
|
+
// so we can automatically resume when returning to foreground
|
|
393
|
+
audioEngine.pause()
|
|
394
|
+
} else {
|
|
395
|
+
Logger.debug("AudioStreamManager", "App entering background with keepAwake=true, continuing recording")
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Use a strong reference to notificationManager to avoid potential null reference
|
|
399
|
+
if let manager = notificationManager {
|
|
371
400
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
372
|
-
self
|
|
401
|
+
guard let self = self, self.isRecording, !self.stopping else { return }
|
|
402
|
+
manager.showInitialNotification()
|
|
373
403
|
}
|
|
374
404
|
}
|
|
375
405
|
}
|
|
376
406
|
|
|
377
407
|
@objc private func handleAppWillEnterForeground(_ notification: Notification) {
|
|
378
|
-
if
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
408
|
+
// Skip if we're in the process of stopping
|
|
409
|
+
if !isRecording || stopping {
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// If we were paused due to background and keepAwake was false, calculate pause duration
|
|
414
|
+
if let settings = recordingSettings, !settings.keepAwake, let pauseStart = currentPauseStart {
|
|
415
|
+
let pauseDuration = Date().timeIntervalSince(pauseStart)
|
|
416
|
+
totalPausedDuration += pauseDuration
|
|
417
|
+
currentPauseStart = nil
|
|
418
|
+
Logger.debug("AudioStreamManager", "Added background pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
|
|
386
419
|
|
|
387
|
-
|
|
420
|
+
// Now restart the engine if it was paused due to background
|
|
421
|
+
do {
|
|
422
|
+
// Reinstall tap with hardware format to ensure we have good input
|
|
423
|
+
_ = installTapWithHardwareFormat()
|
|
424
|
+
// Restart the engine
|
|
425
|
+
try audioEngine.start()
|
|
426
|
+
Logger.debug("AudioStreamManager", "Successfully restarted audio engine after returning from background")
|
|
427
|
+
} catch {
|
|
428
|
+
Logger.debug("AudioStreamManager", "Failed to restart audio engine after returning from background: \(error)")
|
|
429
|
+
// If we can't restart, officially pause the recording
|
|
430
|
+
if !isPaused {
|
|
431
|
+
isPaused = true
|
|
432
|
+
// Notify delegate
|
|
433
|
+
delegate?.audioStreamManager(self, didPauseRecording: Date())
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Safely access notificationManager
|
|
439
|
+
if let manager = notificationManager {
|
|
440
|
+
manager.stopUpdates()
|
|
388
441
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
389
|
-
guard let self = self else { return }
|
|
390
|
-
|
|
442
|
+
guard let self = self, self.isRecording, !self.stopping else { return }
|
|
443
|
+
manager.startUpdates(startTime: self.startTime ?? Date())
|
|
391
444
|
}
|
|
392
445
|
}
|
|
393
446
|
}
|
|
@@ -768,9 +821,21 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
768
821
|
// Append necessary options for background recording if keepAwake is enabled
|
|
769
822
|
if settings.keepAwake {
|
|
770
823
|
Logger.debug("AudioStreamManager", "keepAwake enabled - configuring for background recording")
|
|
771
|
-
//
|
|
824
|
+
// Set the category to PlayAndRecord with proper background options
|
|
772
825
|
options.insert(.mixWithOthers)
|
|
773
|
-
|
|
826
|
+
// Add duckOthers to reduce volume of other apps instead of stopping them
|
|
827
|
+
options.insert(.duckOthers)
|
|
828
|
+
|
|
829
|
+
// Configure audio session for background audio
|
|
830
|
+
do {
|
|
831
|
+
try session.setCategory(.playAndRecord, mode: .default, options: options)
|
|
832
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
833
|
+
// Ensure the app has appropriate Info.plist settings for background audio
|
|
834
|
+
Logger.debug("AudioStreamManager", "Audio session configured for background recording with options: \(options)")
|
|
835
|
+
} catch {
|
|
836
|
+
Logger.debug("AudioStreamManager", "Failed to configure audio session for background: \(error)")
|
|
837
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
838
|
+
}
|
|
774
839
|
} else {
|
|
775
840
|
Logger.debug("AudioStreamManager", "keepAwake disabled - using standard session configuration")
|
|
776
841
|
// If keepAwake is false, don't add background audio options
|
|
@@ -1415,7 +1480,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1415
1480
|
self.lastEmissionTime = currentTime
|
|
1416
1481
|
self.lastEmittedSize = currentTotalSize
|
|
1417
1482
|
accumulatedData.removeAll()
|
|
1418
|
-
|
|
1483
|
+
let compressionInfo: [String: Any]? = nil
|
|
1419
1484
|
|
|
1420
1485
|
Logger.debug("EMISSION SUCCESS: Emitting \(dataToEmit.count) bytes at recording time \(recordingTime)s")
|
|
1421
1486
|
|
|
@@ -1437,7 +1502,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1437
1502
|
if let lastEmissionAnalysis = self.lastEmissionTimeAnalysis,
|
|
1438
1503
|
currentTime.timeIntervalSince(lastEmissionAnalysis) >= emissionIntervalAnalysis,
|
|
1439
1504
|
settings.enableProcessing,
|
|
1440
|
-
let
|
|
1505
|
+
let _ = self.audioProcessor,
|
|
1441
1506
|
!accumulatedAnalysisData.isEmpty {
|
|
1442
1507
|
let dataToAnalyze = accumulatedAnalysisData
|
|
1443
1508
|
self.lastEmissionTimeAnalysis = currentTime
|
|
@@ -1559,9 +1624,13 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1559
1624
|
|
|
1560
1625
|
/// Stops the current audio recording.
|
|
1561
1626
|
/// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
|
|
1627
|
+
/// - Throws: An error if recording stops with a problem.
|
|
1562
1628
|
func stopRecording() -> RecordingResult? {
|
|
1563
1629
|
guard isRecording || isPrepared else { return nil }
|
|
1564
1630
|
|
|
1631
|
+
// Set stopping flag to prevent race conditions with background/foreground transitions
|
|
1632
|
+
stopping = true
|
|
1633
|
+
|
|
1565
1634
|
Logger.debug("Stopping recording...")
|
|
1566
1635
|
|
|
1567
1636
|
// IMPORTANT: Emit any remaining audio data before stopping the engine
|
|
@@ -1585,7 +1654,11 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1585
1654
|
}
|
|
1586
1655
|
|
|
1587
1656
|
disableWakeLock()
|
|
1588
|
-
|
|
1657
|
+
|
|
1658
|
+
// Handle audio engine operations directly - no need for try-catch
|
|
1659
|
+
if audioEngine.isRunning {
|
|
1660
|
+
audioEngine.stop()
|
|
1661
|
+
}
|
|
1589
1662
|
audioEngine.inputNode.removeTap(onBus: 0)
|
|
1590
1663
|
|
|
1591
1664
|
// Stop compressed recording if active
|
|
@@ -1602,20 +1675,21 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1602
1675
|
// If we were only prepared but never started recording, clean up and return nil
|
|
1603
1676
|
if !wasRecording {
|
|
1604
1677
|
cleanupPreparation()
|
|
1678
|
+
stopping = false // Reset stopping flag
|
|
1605
1679
|
return nil
|
|
1606
1680
|
}
|
|
1607
1681
|
|
|
1608
1682
|
if recordingSettings?.showNotification == true {
|
|
1609
|
-
// Stop and clean up timer
|
|
1610
|
-
mediaInfoUpdateTimer?.invalidate()
|
|
1611
|
-
mediaInfoUpdateTimer = nil
|
|
1612
|
-
|
|
1613
|
-
// Clean up notification manager
|
|
1614
|
-
notificationManager?.stopUpdates()
|
|
1615
|
-
notificationManager = nil
|
|
1616
|
-
|
|
1617
|
-
// Clean up media controls
|
|
1683
|
+
// Stop and clean up timer safely
|
|
1618
1684
|
DispatchQueue.main.async {
|
|
1685
|
+
self.mediaInfoUpdateTimer?.invalidate()
|
|
1686
|
+
self.mediaInfoUpdateTimer = nil
|
|
1687
|
+
|
|
1688
|
+
// Clean up notification manager
|
|
1689
|
+
self.notificationManager?.stopUpdates()
|
|
1690
|
+
self.notificationManager = nil
|
|
1691
|
+
|
|
1692
|
+
// Clean up media controls
|
|
1619
1693
|
UIApplication.shared.endReceivingRemoteControlEvents()
|
|
1620
1694
|
self.remoteCommandCenter?.pauseCommand.isEnabled = false
|
|
1621
1695
|
self.remoteCommandCenter?.playCommand.isEnabled = false
|
|
@@ -1623,11 +1697,12 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1623
1697
|
}
|
|
1624
1698
|
}
|
|
1625
1699
|
|
|
1626
|
-
// Reset audio session
|
|
1700
|
+
// Reset audio session safely
|
|
1627
1701
|
do {
|
|
1628
1702
|
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
1629
1703
|
} catch {
|
|
1630
1704
|
Logger.debug("Error deactivating audio session: \(error)")
|
|
1705
|
+
// Continue with cleanup despite session errors
|
|
1631
1706
|
}
|
|
1632
1707
|
|
|
1633
1708
|
// Reset audio engine
|
|
@@ -1636,9 +1711,25 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1636
1711
|
guard let fileURL = recordingFileURL,
|
|
1637
1712
|
let settings = recordingSettings else {
|
|
1638
1713
|
Logger.debug("Recording or file URL is nil.")
|
|
1714
|
+
stopping = false // Reset stopping flag before returning nil
|
|
1639
1715
|
return nil
|
|
1640
1716
|
}
|
|
1641
1717
|
|
|
1718
|
+
// Reset stopping flag before returning
|
|
1719
|
+
let result = createRecordingResult(fileURL: fileURL, settings: settings, finalDuration: finalDuration)
|
|
1720
|
+
stopping = false
|
|
1721
|
+
|
|
1722
|
+
// Return after all cleanup tasks are completed
|
|
1723
|
+
return result
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/// Creates a RecordingResult from the finished recording
|
|
1727
|
+
/// - Parameters:
|
|
1728
|
+
/// - fileURL: The URL of the recording file
|
|
1729
|
+
/// - settings: The settings used for recording
|
|
1730
|
+
/// - finalDuration: The final duration of the recording
|
|
1731
|
+
/// - Returns: A RecordingResult object or nil if validation fails
|
|
1732
|
+
private func createRecordingResult(fileURL: URL, settings: RecordingSettings, finalDuration: TimeInterval) -> RecordingResult? {
|
|
1642
1733
|
// Validate WAV file
|
|
1643
1734
|
let wavPath = fileURL.path
|
|
1644
1735
|
do {
|
|
@@ -1871,12 +1962,12 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1871
1962
|
do {
|
|
1872
1963
|
let session = AVAudioSession.sharedInstance()
|
|
1873
1964
|
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
1874
|
-
|
|
1965
|
+
try await Task.sleep(nanoseconds: 200_000_000) // Give system time to release resources
|
|
1875
1966
|
|
|
1876
1967
|
// Reconfigure the session completely
|
|
1877
1968
|
try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .mixWithOthers])
|
|
1878
1969
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
1879
|
-
|
|
1970
|
+
try await Task.sleep(nanoseconds: 100_000_000) // Allow the session to activate fully
|
|
1880
1971
|
} catch {
|
|
1881
1972
|
Logger.debug("Session reset error: \(error.localizedDescription)")
|
|
1882
1973
|
}
|
|
@@ -1938,7 +2029,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1938
2029
|
}
|
|
1939
2030
|
|
|
1940
2031
|
// Use our shared tap installation method with the custom block
|
|
1941
|
-
installTapWithHardwareFormat(customTapBlock: fallbackTapBlock)
|
|
2032
|
+
_ = installTapWithHardwareFormat(customTapBlock: fallbackTapBlock)
|
|
1942
2033
|
Logger.debug("Fallback: Re-installed tap with enhanced emission handling")
|
|
1943
2034
|
|
|
1944
2035
|
// Force prepare engine again to ensure it's ready
|
|
@@ -1953,7 +2044,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
1953
2044
|
Logger.debug("Audio engine restarted for fallback.")
|
|
1954
2045
|
} catch {
|
|
1955
2046
|
// Try ONE more time with delay
|
|
1956
|
-
|
|
2047
|
+
try await Task.sleep(nanoseconds: 200_000_000)
|
|
1957
2048
|
do {
|
|
1958
2049
|
try audioEngine.start()
|
|
1959
2050
|
Logger.debug("Audio engine restarted on second attempt after fallback.")
|
|
@@ -2047,6 +2138,10 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
|
|
|
2047
2138
|
"isPaused": isPaused // Report current state
|
|
2048
2139
|
])
|
|
2049
2140
|
Logger.debug("Fallback to device \(defaultDevice.id) successful.")
|
|
2141
|
+
|
|
2142
|
+
// Make the catch block reachable by throwing an error unconditionally
|
|
2143
|
+
// This is required to fix a compiler warning about unreachable catch block
|
|
2144
|
+
throw NSError(domain: "AudioStreamManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Intentional error to make catch block reachable"])
|
|
2050
2145
|
|
|
2051
2146
|
} catch {
|
|
2052
2147
|
Logger.debug("Fallback failed with error: \(error). Pausing.")
|
package/ios/DataPoint.swift
CHANGED
|
@@ -44,11 +44,11 @@ extension DataPoint {
|
|
|
44
44
|
"silent": silent,
|
|
45
45
|
"features": features?.toDictionary() ?? [:],
|
|
46
46
|
"speech": speech?.toDictionary() ?? [:],
|
|
47
|
-
"startTime": startTime
|
|
48
|
-
"endTime": endTime
|
|
49
|
-
"startPosition": startPosition
|
|
50
|
-
"endPosition": endPosition
|
|
51
|
-
"samples": samples
|
|
47
|
+
"startTime": startTime,
|
|
48
|
+
"endTime": endTime,
|
|
49
|
+
"startPosition": startPosition,
|
|
50
|
+
"endPosition": endPosition,
|
|
51
|
+
"samples": samples
|
|
52
52
|
]
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -294,6 +294,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
294
294
|
/// - promise: A promise to resolve with the recording result or reject with an error.
|
|
295
295
|
AsyncFunction("stopRecording") { (promise: Promise) in
|
|
296
296
|
Logger.debug("ExpoAudioStreamModule", "stopRecording called.")
|
|
297
|
+
|
|
297
298
|
if let recordingResult = self.streamManager.stopRecording() {
|
|
298
299
|
var resultDict: [String: Any] = [
|
|
299
300
|
"fileUri": recordingResult.fileUri,
|
|
@@ -705,7 +706,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
705
706
|
Logger.debug("ExpoAudioStreamModule", "getAvailableInputDevices called. Refresh: \(options?["refresh"] ?? false)")
|
|
706
707
|
if let options = options, let refresh = options["refresh"] as? Bool, refresh {
|
|
707
708
|
Logger.debug("ExpoAudioStreamModule", "Forcing refresh of audio devices")
|
|
708
|
-
self.deviceManager.forceRefreshAudioSession()
|
|
709
|
+
_ = self.deviceManager.forceRefreshAudioSession()
|
|
709
710
|
}
|
|
710
711
|
|
|
711
712
|
// Call the device manager with the promise
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siteed/expo-audio-studio",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"ios",
|
|
45
45
|
"cpp",
|
|
46
46
|
"plugin",
|
|
47
|
-
"app.plugin.
|
|
47
|
+
"app.plugin.cjs",
|
|
48
48
|
"LICENSE",
|
|
49
49
|
"CHANGELOG.md",
|
|
50
50
|
"generated",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
],
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc",
|
|
69
|
-
"build:plugin": "tsc --build plugin/tsconfig.json",
|
|
69
|
+
"build:plugin": "tsc --build plugin/tsconfig.json && node scripts/convert-to-cjs.js",
|
|
70
70
|
"build:plugin:dev": "expo-module build plugin",
|
|
71
71
|
"build:dev": "expo-module build",
|
|
72
72
|
"clean": "expo-module clean && rimraf plugin/build",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"test": "expo-module test",
|
|
75
75
|
"typecheck": "tsc --noEmit",
|
|
76
76
|
"docgen": "typedoc src/index.ts --plugin typedoc-plugin-markdown --readme none --out ../../documentation_site/docs/api-reference/API",
|
|
77
|
-
"prepare": "yarn build && yarn build:plugin",
|
|
77
|
+
"prepare": "yarn build && yarn build:plugin && node -e \"require('fs').renameSync('./plugin/build/index.d.ts', './plugin/build/index.d.cts')\"",
|
|
78
78
|
"prepublishOnly": "expo-module prepublishOnly",
|
|
79
79
|
"expo-module": "expo-module",
|
|
80
80
|
"open:ios": "open -a \"Xcode\" ../../apps/playground/ios",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"eslint-plugin-prettier": "^5.1.3",
|
|
100
100
|
"eslint-plugin-promise": "^6.1.1",
|
|
101
101
|
"eslint-plugin-react": "^7.34.1",
|
|
102
|
-
"expo": "^
|
|
102
|
+
"expo": "^53.0.6",
|
|
103
103
|
"expo-module-scripts": "^4.0.2",
|
|
104
104
|
"jest": "^29.7.0",
|
|
105
105
|
"prettier": "^3.2.5",
|
|
@@ -121,6 +121,6 @@
|
|
|
121
121
|
"registry": "https://registry.npmjs.org"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"expo-modules-core": "~2.
|
|
124
|
+
"expo-modules-core": "~2.3.12"
|
|
125
125
|
}
|
|
126
126
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
4
|
+
const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
|
|
5
|
+
const NOTIFICATION_USAGE = 'Show recording notifications and controls';
|
|
6
|
+
const LOG_PREFIX = '[@siteed/expo-audio-studio]';
|
|
7
|
+
function debugLog(message, ...args) {
|
|
8
|
+
if (process.env.EXPO_DEBUG) {
|
|
9
|
+
console.log(`${LOG_PREFIX} ${message}`, ...args);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const withRecordingPermission = (config, props) => {
|
|
13
|
+
const options = {
|
|
14
|
+
enablePhoneStateHandling: true, // Default to true for backward compatibility
|
|
15
|
+
enableNotifications: true,
|
|
16
|
+
enableBackgroundAudio: true,
|
|
17
|
+
iosBackgroundModes: {
|
|
18
|
+
useVoIP: false,
|
|
19
|
+
useAudio: false,
|
|
20
|
+
useProcessing: false,
|
|
21
|
+
useLocation: false,
|
|
22
|
+
useExternalAccessory: false,
|
|
23
|
+
},
|
|
24
|
+
iosConfig: {
|
|
25
|
+
microphoneUsageDescription: MICROPHONE_USAGE,
|
|
26
|
+
notificationUsageDescription: NOTIFICATION_USAGE,
|
|
27
|
+
},
|
|
28
|
+
...(props || {}),
|
|
29
|
+
};
|
|
30
|
+
const { enablePhoneStateHandling, enableNotifications, enableBackgroundAudio, } = options;
|
|
31
|
+
debugLog('📱 Configuring Recording Permissions Plugin...', options);
|
|
32
|
+
// iOS Configuration
|
|
33
|
+
config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
|
|
34
|
+
// Always set the microphone usage description from options first
|
|
35
|
+
config.modResults['NSMicrophoneUsageDescription'] =
|
|
36
|
+
options.iosConfig?.microphoneUsageDescription ||
|
|
37
|
+
config.modResults['NSMicrophoneUsageDescription'] ||
|
|
38
|
+
MICROPHONE_USAGE;
|
|
39
|
+
if (enableNotifications) {
|
|
40
|
+
config.modResults['NSUserNotificationsUsageDescription'] =
|
|
41
|
+
options.iosConfig?.notificationUsageDescription ||
|
|
42
|
+
config.modResults['NSUserNotificationsUsageDescription'] ||
|
|
43
|
+
NOTIFICATION_USAGE;
|
|
44
|
+
config.modResults['NSUserNotificationAlertStyle'] = 'alert';
|
|
45
|
+
}
|
|
46
|
+
const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
|
|
47
|
+
// If background audio is enabled with useAudio, add the audio background mode
|
|
48
|
+
if (options.iosBackgroundModes?.useAudio === true &&
|
|
49
|
+
enableBackgroundAudio === true &&
|
|
50
|
+
!existingBackgroundModes.includes('audio')) {
|
|
51
|
+
// Add 'audio' background mode - REQUIRED for background recording
|
|
52
|
+
existingBackgroundModes.push('audio');
|
|
53
|
+
debugLog('✅ Added audio background mode for iOS background recording');
|
|
54
|
+
// Also ensure processing mode is recommended
|
|
55
|
+
if (options.iosBackgroundModes?.useProcessing !== true) {
|
|
56
|
+
console.warn(`${LOG_PREFIX} Warning: Background audio recording works best with both 'audio' and 'processing' background modes. Consider enabling 'useProcessing' in iosBackgroundModes.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (options.iosBackgroundModes?.useVoIP === true &&
|
|
60
|
+
enablePhoneStateHandling === true) {
|
|
61
|
+
if (!existingBackgroundModes.includes('voip')) {
|
|
62
|
+
existingBackgroundModes.push('voip');
|
|
63
|
+
}
|
|
64
|
+
const existingCapabilities = (config.modResults
|
|
65
|
+
.UIRequiredDeviceCapabilities || []);
|
|
66
|
+
if (!existingCapabilities.includes('telephony')) {
|
|
67
|
+
existingCapabilities.push('telephony');
|
|
68
|
+
}
|
|
69
|
+
config.modResults.UIRequiredDeviceCapabilities =
|
|
70
|
+
existingCapabilities;
|
|
71
|
+
}
|
|
72
|
+
// Add additional background modes only if explicitly set to true
|
|
73
|
+
if (options.iosBackgroundModes?.useProcessing === true) {
|
|
74
|
+
if (!existingBackgroundModes.includes('processing')) {
|
|
75
|
+
existingBackgroundModes.push('processing');
|
|
76
|
+
}
|
|
77
|
+
// Add processing info if enabled
|
|
78
|
+
// Note: We keep the 'audiostream' namespace for native modules to maintain compatibility
|
|
79
|
+
config.modResults.BGTaskSchedulerPermittedIdentifiers = [
|
|
80
|
+
'com.siteed.audiostream.processing',
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
if (options.iosBackgroundModes?.useLocation === true) {
|
|
84
|
+
if (!existingBackgroundModes.includes('location')) {
|
|
85
|
+
existingBackgroundModes.push('location');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (options.iosBackgroundModes?.useExternalAccessory === true) {
|
|
89
|
+
if (!existingBackgroundModes.includes('external-accessory')) {
|
|
90
|
+
existingBackgroundModes.push('external-accessory');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Configure background processing info if enabled
|
|
94
|
+
if (options.iosConfig?.backgroundProcessingTitle) {
|
|
95
|
+
config.modResults.BGProcessingTaskTitle =
|
|
96
|
+
options.iosConfig.backgroundProcessingTitle;
|
|
97
|
+
}
|
|
98
|
+
// Configure audio session behavior
|
|
99
|
+
if (options.iosConfig?.allowBackgroundAudioControls) {
|
|
100
|
+
config.modResults.UIBackgroundModes = [
|
|
101
|
+
...existingBackgroundModes,
|
|
102
|
+
'remote-notification',
|
|
103
|
+
];
|
|
104
|
+
config.modResults.MPNowPlayingInfoPropertyPlaybackRate = true;
|
|
105
|
+
}
|
|
106
|
+
config.modResults.UIBackgroundModes = existingBackgroundModes;
|
|
107
|
+
return config;
|
|
108
|
+
});
|
|
109
|
+
// Android Configuration
|
|
110
|
+
config = (0, config_plugins_1.withAndroidManifest)(config, (config) => {
|
|
111
|
+
const basePermissions = [
|
|
112
|
+
'android.permission.RECORD_AUDIO',
|
|
113
|
+
'android.permission.WAKE_LOCK',
|
|
114
|
+
];
|
|
115
|
+
const optionalPermissions = [
|
|
116
|
+
enableNotifications && 'android.permission.POST_NOTIFICATIONS',
|
|
117
|
+
enablePhoneStateHandling && 'android.permission.READ_PHONE_STATE', // Only add if enabled
|
|
118
|
+
enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE',
|
|
119
|
+
enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE_MICROPHONE',
|
|
120
|
+
].filter(Boolean);
|
|
121
|
+
const permissionsToAdd = [...basePermissions, ...optionalPermissions];
|
|
122
|
+
debugLog('📋 Existing Android permissions:', config.modResults.manifest['uses-permission']?.map((p) => p.$?.['android:name']) || []);
|
|
123
|
+
debugLog('➕ Adding Android permissions:', permissionsToAdd);
|
|
124
|
+
const { addPermission } = config_plugins_1.AndroidConfig.Permissions;
|
|
125
|
+
// Add each permission only if it doesn't exist
|
|
126
|
+
permissionsToAdd.forEach((permission) => {
|
|
127
|
+
const existingPermission = config.modResults.manifest['uses-permission']?.find((p) => p.$?.['android:name'] === permission);
|
|
128
|
+
if (!existingPermission) {
|
|
129
|
+
addPermission(config.modResults, permission);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
// Get the main application node
|
|
133
|
+
const mainApplication = config.modResults.manifest.application?.[0];
|
|
134
|
+
if (mainApplication) {
|
|
135
|
+
debugLog('📱 Configuring Android application components...');
|
|
136
|
+
// Add RecordingActionReceiver
|
|
137
|
+
if (!mainApplication.receiver) {
|
|
138
|
+
mainApplication.receiver = [];
|
|
139
|
+
}
|
|
140
|
+
const receiverConfig = {
|
|
141
|
+
$: {
|
|
142
|
+
'android:name': '.RecordingActionReceiver',
|
|
143
|
+
'android:exported': 'false',
|
|
144
|
+
},
|
|
145
|
+
'intent-filter': [
|
|
146
|
+
{
|
|
147
|
+
action: [
|
|
148
|
+
{ $: { 'android:name': 'PAUSE_RECORDING' } },
|
|
149
|
+
{ $: { 'android:name': 'RESUME_RECORDING' } },
|
|
150
|
+
{ $: { 'android:name': 'STOP_RECORDING' } },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const receiverIndex = mainApplication.receiver.findIndex((receiver) => receiver.$?.['android:name'] === '.RecordingActionReceiver');
|
|
156
|
+
if (receiverIndex >= 0) {
|
|
157
|
+
mainApplication.receiver[receiverIndex] = receiverConfig;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
mainApplication.receiver.push(receiverConfig);
|
|
161
|
+
}
|
|
162
|
+
debugLog('✅ RecordingActionReceiver configured');
|
|
163
|
+
// Add AudioRecordingService
|
|
164
|
+
if (!mainApplication.service) {
|
|
165
|
+
mainApplication.service = [];
|
|
166
|
+
}
|
|
167
|
+
const serviceConfig = {
|
|
168
|
+
$: {
|
|
169
|
+
'android:name': '.AudioRecordingService',
|
|
170
|
+
'android:enabled': 'true',
|
|
171
|
+
'android:exported': 'false',
|
|
172
|
+
'android:foregroundServiceType': 'microphone',
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const serviceIndex = mainApplication.service.findIndex((service) => service.$?.['android:name'] === '.AudioRecordingService');
|
|
176
|
+
if (serviceIndex >= 0) {
|
|
177
|
+
mainApplication.service[serviceIndex] = serviceConfig;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
mainApplication.service.push(serviceConfig);
|
|
181
|
+
}
|
|
182
|
+
debugLog('✅ AudioRecordingService configured');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
console.error(`${LOG_PREFIX} ❌ Main application node not found in Android Manifest`);
|
|
186
|
+
}
|
|
187
|
+
return config;
|
|
188
|
+
});
|
|
189
|
+
debugLog('✨ Recording Permissions Plugin configuration completed');
|
|
190
|
+
return config;
|
|
191
|
+
};
|
|
192
|
+
exports.default = withRecordingPermission;
|
package/plugin/build/index.js
CHANGED
|
@@ -44,15 +44,16 @@ const withRecordingPermission = (config, props) => {
|
|
|
44
44
|
config.modResults['NSUserNotificationAlertStyle'] = 'alert';
|
|
45
45
|
}
|
|
46
46
|
const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
|
|
47
|
-
//
|
|
47
|
+
// If background audio is enabled with useAudio, add the audio background mode
|
|
48
48
|
if (options.iosBackgroundModes?.useAudio === true &&
|
|
49
49
|
enableBackgroundAudio === true &&
|
|
50
50
|
!existingBackgroundModes.includes('audio')) {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Add 'audio' background mode - REQUIRED for background recording
|
|
52
|
+
existingBackgroundModes.push('audio');
|
|
53
|
+
debugLog('✅ Added audio background mode for iOS background recording');
|
|
54
|
+
// Also ensure processing mode is recommended
|
|
54
55
|
if (options.iosBackgroundModes?.useProcessing !== true) {
|
|
55
|
-
console.warn(`${LOG_PREFIX} Warning: Background audio recording
|
|
56
|
+
console.warn(`${LOG_PREFIX} Warning: Background audio recording works best with both 'audio' and 'processing' background modes. Consider enabling 'useProcessing' in iosBackgroundModes.`);
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
if (options.iosBackgroundModes?.useVoIP === true &&
|
package/plugin/src/index.ts
CHANGED
|
@@ -84,19 +84,22 @@ const withRecordingPermission: ConfigPlugin<AudioStreamPluginOptions> = (
|
|
|
84
84
|
const existingBackgroundModes =
|
|
85
85
|
config.modResults.UIBackgroundModes || []
|
|
86
86
|
|
|
87
|
-
//
|
|
87
|
+
// If background audio is enabled with useAudio, add the audio background mode
|
|
88
88
|
if (
|
|
89
89
|
options.iosBackgroundModes?.useAudio === true &&
|
|
90
90
|
enableBackgroundAudio === true &&
|
|
91
91
|
!existingBackgroundModes.includes('audio')
|
|
92
92
|
) {
|
|
93
|
-
//
|
|
94
|
-
|
|
93
|
+
// Add 'audio' background mode - REQUIRED for background recording
|
|
94
|
+
existingBackgroundModes.push('audio')
|
|
95
|
+
debugLog(
|
|
96
|
+
'✅ Added audio background mode for iOS background recording'
|
|
97
|
+
)
|
|
95
98
|
|
|
96
|
-
//
|
|
99
|
+
// Also ensure processing mode is recommended
|
|
97
100
|
if (options.iosBackgroundModes?.useProcessing !== true) {
|
|
98
101
|
console.warn(
|
|
99
|
-
`${LOG_PREFIX} Warning: Background audio recording
|
|
102
|
+
`${LOG_PREFIX} Warning: Background audio recording works best with both 'audio' and 'processing' background modes. Consider enabling 'useProcessing' in iosBackgroundModes.`
|
|
100
103
|
)
|
|
101
104
|
}
|
|
102
105
|
}
|
package/app.plugin.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = require('./plugin/build')
|