@siteed/expo-audio-stream 0.2.4 → 0.3.1
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 +4 -1
- package/ios/AudioStreamManager.swift +71 -40
- package/ios/ExpoAudioStreamModule.swift +15 -12
- package/ios/Logger.swift +7 -0
- package/package.json +5 -4
- package/plugin/build/index.js +8 -0
- package/plugin/src/index.ts +16 -0
package/README.md
CHANGED
|
@@ -122,5 +122,8 @@ or set the DEBUG environment variable to `expo-audio-stream:*`
|
|
|
122
122
|
|
|
123
123
|
### TODO
|
|
124
124
|
this package is still in development, and there are a few things that need to be done:
|
|
125
|
-
-
|
|
125
|
+
- Add resume (vs currently use start) support and implement pause on iOS.
|
|
126
|
+
- Multi format support: Extend support to other audio formats beyond WAV, such as MP3 or AAC
|
|
127
|
+
- Integrate an audio processing library for optional audio analysis, such as equalization, noise reduction, or volume normalization.
|
|
128
|
+
- Implement a more robust error handling system to provide detailed error messages and recovery options.
|
|
126
129
|
|
|
@@ -69,18 +69,6 @@ class AudioStreamManager: NSObject {
|
|
|
69
69
|
|
|
70
70
|
override init() {
|
|
71
71
|
super.init()
|
|
72
|
-
configureAudioSession()
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private func configureAudioSession() {
|
|
76
|
-
let session = AVAudioSession.sharedInstance()
|
|
77
|
-
do {
|
|
78
|
-
try session.setCategory(.playAndRecord, mode: .default)
|
|
79
|
-
try session.setActive(true)
|
|
80
|
-
print("Audio session configured successfully.")
|
|
81
|
-
} catch {
|
|
82
|
-
print("Failed to set up audio session: \(error.localizedDescription)")
|
|
83
|
-
}
|
|
84
72
|
}
|
|
85
73
|
|
|
86
74
|
@objc func handleAudioSessionInterruption(notification: Notification) {
|
|
@@ -90,6 +78,7 @@ class AudioStreamManager: NSObject {
|
|
|
90
78
|
return
|
|
91
79
|
}
|
|
92
80
|
|
|
81
|
+
Logger.debug("audio session interruption \(type)")
|
|
93
82
|
if type == .began {
|
|
94
83
|
// Pause your audio recording
|
|
95
84
|
} else if type == .ended {
|
|
@@ -97,6 +86,7 @@ class AudioStreamManager: NSObject {
|
|
|
97
86
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
98
87
|
if options.contains(.shouldResume) {
|
|
99
88
|
// Resume your audio recording
|
|
89
|
+
Logger.debug("Resume audio recording \(recordingUUID!)")
|
|
100
90
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
101
91
|
}
|
|
102
92
|
}
|
|
@@ -115,9 +105,8 @@ class AudioStreamManager: NSObject {
|
|
|
115
105
|
let wavHeader = createWavHeader(dataSize: 0) // Initially set data size to 0
|
|
116
106
|
fileHandle.write(wavHeader)
|
|
117
107
|
fileHandle.closeFile()
|
|
118
|
-
print("Recording file with header created at:", fileURL.path)
|
|
119
108
|
} catch {
|
|
120
|
-
|
|
109
|
+
Logger.debug("Failed to write WAV header: \(error.localizedDescription)")
|
|
121
110
|
return nil
|
|
122
111
|
}
|
|
123
112
|
}
|
|
@@ -131,8 +120,8 @@ class AudioStreamManager: NSObject {
|
|
|
131
120
|
let channels = UInt32(recordingSettings!.numberOfChannels)
|
|
132
121
|
let bitDepth = UInt32(recordingSettings!.bitDepth)
|
|
133
122
|
|
|
134
|
-
// Calculate byteRate
|
|
135
123
|
let byteRate = sampleRate * channels * (bitDepth / 8)
|
|
124
|
+
let blockAlign = channels * (bitDepth / 8)
|
|
136
125
|
|
|
137
126
|
// "RIFF" chunk descriptor
|
|
138
127
|
header.append(contentsOf: "RIFF".utf8)
|
|
@@ -172,62 +161,104 @@ class AudioStreamManager: NSObject {
|
|
|
172
161
|
|
|
173
162
|
func startRecording(settings: RecordingSettings, intervalMilliseconds: Int) -> String? {
|
|
174
163
|
guard !isRecording else {
|
|
175
|
-
|
|
164
|
+
Logger.debug("Debug: Recording is already in progress.")
|
|
176
165
|
return nil
|
|
177
166
|
}
|
|
178
|
-
|
|
167
|
+
|
|
168
|
+
guard !audioEngine.isRunning else {
|
|
169
|
+
Logger.debug("Debug: Audio engine already running.")
|
|
170
|
+
return nil
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
recordingSettings = settings
|
|
174
|
+
|
|
175
|
+
// Determine the commonFormat based on bitDepth
|
|
176
|
+
let commonFormat: AVAudioCommonFormat
|
|
177
|
+
switch settings.bitDepth {
|
|
178
|
+
case 16:
|
|
179
|
+
commonFormat = .pcmFormatInt16
|
|
180
|
+
case 32:
|
|
181
|
+
commonFormat = .pcmFormatInt32
|
|
182
|
+
default:
|
|
183
|
+
Logger.debug("Unsupported bit depth. Defaulting to 16-bit PCM")
|
|
184
|
+
commonFormat = .pcmFormatInt16
|
|
185
|
+
recordingSettings?.bitDepth = 16
|
|
186
|
+
}
|
|
187
|
+
|
|
179
188
|
emissionInterval = max(100.0, Double(intervalMilliseconds)) / 1000.0
|
|
180
189
|
lastEmissionTime = Date()
|
|
181
|
-
recordingSettings = settings
|
|
182
190
|
|
|
183
191
|
let session = AVAudioSession.sharedInstance()
|
|
184
192
|
do {
|
|
185
|
-
|
|
193
|
+
Logger.debug("Debug: Configuring audio session with sample rate: \(settings.sampleRate) Hz")
|
|
186
194
|
try session.setPreferredSampleRate(settings.sampleRate)
|
|
187
195
|
try session.setPreferredIOBufferDuration(1024 / settings.sampleRate)
|
|
188
196
|
try session.setCategory(.playAndRecord)
|
|
189
197
|
try session.setActive(true)
|
|
190
|
-
|
|
198
|
+
Logger.debug("Debug: Audio session activated successfully.")
|
|
191
199
|
} catch {
|
|
192
|
-
|
|
200
|
+
Logger.debug("Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
|
|
193
201
|
return nil
|
|
194
202
|
}
|
|
195
|
-
|
|
203
|
+
|
|
196
204
|
NotificationCenter.default.addObserver(self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
|
|
206
|
+
// Correct the format to use 16-bit integer (PCM)
|
|
207
|
+
guard let audioFormat = AVAudioFormat(commonFormat: commonFormat, sampleRate: settings.sampleRate, channels: UInt32(settings.numberOfChannels), interleaved: true) else {
|
|
208
|
+
Logger.debug("Error: Failed to create audio format with the specified bit depth.")
|
|
200
209
|
return nil
|
|
201
210
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: errorFormat) { [weak self] (buffer, time) in
|
|
211
|
+
|
|
212
|
+
audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: audioFormat) { [weak self] (buffer, time) in
|
|
205
213
|
guard let self = self, let fileURL = self.recordingFileURL else {
|
|
206
|
-
|
|
214
|
+
Logger.debug("Error: File URL or self is nil during buffer processing.")
|
|
207
215
|
return
|
|
208
216
|
}
|
|
217
|
+
let formatDescription = describeAudioFormat(buffer.format)
|
|
218
|
+
Logger.debug("Debug: Buffer format - \(formatDescription)")
|
|
219
|
+
|
|
209
220
|
self.processAudioBuffer(buffer, fileURL: fileURL)
|
|
210
221
|
}
|
|
211
|
-
|
|
222
|
+
|
|
212
223
|
recordingFileURL = createRecordingFile()
|
|
213
224
|
if recordingFileURL == nil {
|
|
214
|
-
|
|
225
|
+
Logger.debug("Error: Failed to create recording file.")
|
|
215
226
|
return nil
|
|
216
227
|
}
|
|
217
|
-
|
|
228
|
+
|
|
218
229
|
do {
|
|
219
230
|
startTime = Date()
|
|
220
231
|
try audioEngine.start()
|
|
221
232
|
isRecording = true
|
|
222
|
-
|
|
223
|
-
return recordingFileURL?.
|
|
233
|
+
Logger.debug("Debug: Recording started successfully.")
|
|
234
|
+
return recordingFileURL?.path
|
|
224
235
|
} catch {
|
|
225
|
-
|
|
236
|
+
Logger.debug("Error: Could not start the audio engine: \(error.localizedDescription)")
|
|
226
237
|
isRecording = false
|
|
227
238
|
return nil
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
|
-
|
|
241
|
+
|
|
242
|
+
func describeAudioFormat(_ format: AVAudioFormat) -> String {
|
|
243
|
+
let sampleRate = format.sampleRate
|
|
244
|
+
let channelCount = format.channelCount
|
|
245
|
+
let bitDepth: String
|
|
246
|
+
|
|
247
|
+
switch format.commonFormat {
|
|
248
|
+
case .pcmFormatInt16:
|
|
249
|
+
bitDepth = "16-bit Int"
|
|
250
|
+
case .pcmFormatInt32:
|
|
251
|
+
bitDepth = "32-bit Int"
|
|
252
|
+
case .pcmFormatFloat32:
|
|
253
|
+
bitDepth = "32-bit Float"
|
|
254
|
+
case .pcmFormatFloat64:
|
|
255
|
+
bitDepth = "64-bit Float"
|
|
256
|
+
default:
|
|
257
|
+
bitDepth = "Unknown Format"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return "Sample Rate: \(sampleRate), Channels: \(channelCount), Format: \(bitDepth)"
|
|
261
|
+
}
|
|
231
262
|
|
|
232
263
|
func stopRecording() -> RecordingResult? {
|
|
233
264
|
audioEngine.stop()
|
|
@@ -297,19 +328,19 @@ class AudioStreamManager: NSObject {
|
|
|
297
328
|
}
|
|
298
329
|
let data = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
|
|
299
330
|
|
|
300
|
-
print("Writing data size: \(data.count) bytes") // Debug: Check the size of data being written
|
|
331
|
+
// print("Writing data size: \(data.count) bytes") // Debug: Check the size of data being written
|
|
301
332
|
fileHandle.seekToEndOfFile()
|
|
302
333
|
fileHandle.write(data)
|
|
303
334
|
fileHandle.closeFile()
|
|
304
335
|
|
|
305
336
|
totalDataSize += Int64(data.count)
|
|
306
|
-
print("Total data size written: \(totalDataSize) bytes") // Debug: Check total data written
|
|
307
|
-
|
|
337
|
+
// print("Total data size written: \(totalDataSize) bytes") // Debug: Check total data written
|
|
338
|
+
|
|
308
339
|
let currentTime = Date()
|
|
309
340
|
if let lastEmissionTime = lastEmissionTime, currentTime.timeIntervalSince(lastEmissionTime) >= emissionInterval {
|
|
310
341
|
if let startTime = startTime {
|
|
311
342
|
let recordingTime = currentTime.timeIntervalSince(startTime)
|
|
312
|
-
print("Emitting data: Recording time \(recordingTime) seconds, Data size \(totalDataSize) bytes")
|
|
343
|
+
// print("Emitting data: Recording time \(recordingTime) seconds, Data size \(totalDataSize) bytes")
|
|
313
344
|
self.delegate?.audioStreamManager(self, didReceiveAudioData: data, recordingTime: recordingTime, totalDataSize: totalDataSize)
|
|
314
345
|
self.lastEmissionTime = currentTime // Update last emission time
|
|
315
346
|
self.lastEmittedSize = totalDataSize
|
|
@@ -47,7 +47,8 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
47
47
|
let resultDict: [String: Any] = [
|
|
48
48
|
"fileUri": recordingResult.fileUri,
|
|
49
49
|
"duration": recordingResult.duration,
|
|
50
|
-
"size": recordingResult.size
|
|
50
|
+
"size": recordingResult.size,
|
|
51
|
+
"mimeType": recordingResult.mimeType,
|
|
51
52
|
]
|
|
52
53
|
promise.resolve(resultDict)
|
|
53
54
|
} else {
|
|
@@ -109,18 +110,20 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
private func clearAudioFiles() {
|
|
112
|
-
let
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
113
|
+
let fileURLs = listAudioFiles() // This now returns full URLs as strings
|
|
114
|
+
fileURLs.forEach { fileURLString in
|
|
115
|
+
if let fileURL = URL(string: fileURLString) {
|
|
116
|
+
do {
|
|
117
|
+
try FileManager.default.removeItem(at: fileURL)
|
|
118
|
+
print("Removed file at:", fileURL.path)
|
|
119
|
+
} catch {
|
|
120
|
+
print("Error removing file at \(fileURL.path):", error.localizedDescription)
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
print("Invalid URL string: \(fileURLString)")
|
|
122
124
|
}
|
|
123
125
|
}
|
|
126
|
+
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
func listAudioFiles() -> [String] {
|
|
@@ -131,7 +134,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
131
134
|
|
|
132
135
|
do {
|
|
133
136
|
let files = try FileManager.default.contentsOfDirectory(at: documentDirectory, includingPropertiesForKeys: nil)
|
|
134
|
-
let audioFiles = files.filter { $0.pathExtension == "wav" }.map { $0.
|
|
137
|
+
let audioFiles = files.filter { $0.pathExtension == "wav" }.map { $0.absoluteString }
|
|
135
138
|
return audioFiles
|
|
136
139
|
} catch {
|
|
137
140
|
print("Error listing audio files:", error.localizedDescription)
|
package/ios/Logger.swift
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siteed/expo-audio-stream",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "stream audio crossplatform",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"base-64": "^1.0.0",
|
|
37
|
-
"debug": "^4.3.4"
|
|
38
|
-
"expo-file-system": "^16.0.9"
|
|
37
|
+
"debug": "^4.3.4"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
41
40
|
"@expo/config-plugins": "^7.9.1",
|
|
@@ -53,12 +52,14 @@
|
|
|
53
52
|
"expo-module-scripts": "^3.4.2",
|
|
54
53
|
"expo-modules-core": "^1.11.12",
|
|
55
54
|
"prettier": "^3.2.5",
|
|
55
|
+
"expo-file-system": "^16.0.9",
|
|
56
56
|
"release-it": "^17.2.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"expo": "*",
|
|
60
60
|
"react": "*",
|
|
61
|
-
"react-native": "*"
|
|
61
|
+
"react-native": "*",
|
|
62
|
+
"expo-file-system": "*"
|
|
62
63
|
},
|
|
63
64
|
"publishConfig": {
|
|
64
65
|
"access": "public",
|
package/plugin/build/index.js
CHANGED
|
@@ -8,11 +8,19 @@ const withRecordingPermission = (config, existingPerms) => {
|
|
|
8
8
|
}
|
|
9
9
|
config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
|
|
10
10
|
config.modResults["NSMicrophoneUsageDescription"] = MICROPHONE_USAGE;
|
|
11
|
+
// Add audio to UIBackgroundModes to allow background audio recording
|
|
12
|
+
const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
|
|
13
|
+
if (!existingBackgroundModes.includes("audio")) {
|
|
14
|
+
existingBackgroundModes.push("audio");
|
|
15
|
+
}
|
|
16
|
+
config.modResults.UIBackgroundModes = existingBackgroundModes;
|
|
11
17
|
return config;
|
|
12
18
|
});
|
|
13
19
|
config = (0, config_plugins_1.withAndroidManifest)(config, (config) => {
|
|
14
20
|
const mainApplication = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
|
|
15
21
|
config_plugins_1.AndroidConfig.Manifest.addMetaDataItemToMainApplication(mainApplication, "android.permission.RECORD_AUDIO", MICROPHONE_USAGE);
|
|
22
|
+
// Add FOREGROUND_SERVICE permission for handling background recording
|
|
23
|
+
config_plugins_1.AndroidConfig.Manifest.addMetaDataItemToMainApplication(mainApplication, "android.permission.FOREGROUND_SERVICE", "This apps needs access to the foreground service to record audio in the background");
|
|
16
24
|
return config;
|
|
17
25
|
});
|
|
18
26
|
return config;
|
package/plugin/src/index.ts
CHANGED
|
@@ -15,6 +15,14 @@ const withRecordingPermission: ConfigPlugin<{
|
|
|
15
15
|
}
|
|
16
16
|
config = withInfoPlist(config, (config) => {
|
|
17
17
|
config.modResults["NSMicrophoneUsageDescription"] = MICROPHONE_USAGE;
|
|
18
|
+
|
|
19
|
+
// Add audio to UIBackgroundModes to allow background audio recording
|
|
20
|
+
const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
|
|
21
|
+
if (!existingBackgroundModes.includes("audio")) {
|
|
22
|
+
existingBackgroundModes.push("audio");
|
|
23
|
+
}
|
|
24
|
+
config.modResults.UIBackgroundModes = existingBackgroundModes;
|
|
25
|
+
|
|
18
26
|
return config;
|
|
19
27
|
});
|
|
20
28
|
|
|
@@ -28,6 +36,14 @@ const withRecordingPermission: ConfigPlugin<{
|
|
|
28
36
|
"android.permission.RECORD_AUDIO",
|
|
29
37
|
MICROPHONE_USAGE,
|
|
30
38
|
);
|
|
39
|
+
|
|
40
|
+
// Add FOREGROUND_SERVICE permission for handling background recording
|
|
41
|
+
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
|
|
42
|
+
mainApplication,
|
|
43
|
+
"android.permission.FOREGROUND_SERVICE",
|
|
44
|
+
"This apps needs access to the foreground service to record audio in the background",
|
|
45
|
+
);
|
|
46
|
+
|
|
31
47
|
return config;
|
|
32
48
|
});
|
|
33
49
|
|