@siteed/expo-audio-stream 1.11.6 → 1.12.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.
@@ -126,7 +126,6 @@ class AudioStreamManager: NSObject {
126
126
  Logger.debug("Audio session interruption began")
127
127
  pauseRecording()
128
128
 
129
- // Notify about the interruption
130
129
  delegate?.audioStreamManager(
131
130
  self,
132
131
  didReceiveInterruption: [
@@ -139,31 +138,24 @@ class AudioStreamManager: NSObject {
139
138
  Logger.debug("Audio session interruption ended")
140
139
  if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
141
140
  let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
142
- if options.contains(.shouldResume) {
143
- if autoResumeAfterInterruption && !wasSuspended {
144
- resumeRecording()
141
+
142
+ // Check if we should auto-resume and the recording wasn't manually paused
143
+ if autoResumeAfterInterruption && !wasSuspended {
144
+ // Add a slight delay to ensure the audio session is fully ready
145
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
146
+ guard let self = self else { return }
147
+ self.resumeRecording()
145
148
  }
146
-
147
- // Notify about the interruption
148
- delegate?.audioStreamManager(
149
- self,
150
- didReceiveInterruption: [
151
- "type": "ended",
152
- "wasSuspended": wasSuspended,
153
- "shouldResume": true
154
- ]
155
- )
156
- } else {
157
- // Notify about the interruption without resume option
158
- delegate?.audioStreamManager(
159
- self,
160
- didReceiveInterruption: [
161
- "type": "ended",
162
- "wasSuspended": wasSuspended,
163
- "shouldResume": false
164
- ]
165
- )
166
149
  }
150
+
151
+ delegate?.audioStreamManager(
152
+ self,
153
+ didReceiveInterruption: [
154
+ "type": "ended",
155
+ "wasSuspended": wasSuspended,
156
+ "shouldResume": options.contains(.shouldResume)
157
+ ]
158
+ )
167
159
  }
168
160
  @unknown default:
169
161
  break
@@ -380,11 +372,51 @@ class AudioStreamManager: NSObject {
380
372
 
381
373
  /// Creates a new recording file.
382
374
  /// - Returns: The URL of the newly created recording file, or nil if creation failed.
383
- private func createRecordingFile() -> URL? {
384
- let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
385
- recordingUUID = UUID()
386
- let fileName = "\(recordingUUID!.uuidString).wav"
387
- let fileURL = documentsDirectory.appendingPathComponent(fileName)
375
+ private func createRecordingFile(isCompressed: Bool = false) -> URL? {
376
+ // Add debug logging
377
+ Logger.debug("Creating recording file - settings filename: \(recordingSettings?.filename ?? "nil")")
378
+
379
+ // Get base directory - use default if no custom directory provided
380
+ let baseDirectory: URL
381
+ if let customDir = recordingSettings?.outputDirectory {
382
+ baseDirectory = URL(fileURLWithPath: customDir)
383
+ Logger.debug("Using custom directory: \(customDir)")
384
+ } else {
385
+ // Use existing default behavior
386
+ baseDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
387
+ Logger.debug("Using default directory: \(baseDirectory.path)")
388
+ }
389
+
390
+ // Use custom filename if provided, otherwise generate UUID
391
+ let baseFilename = recordingSettings?.filename ?? UUID().uuidString
392
+ Logger.debug("Using base filename: \(baseFilename)")
393
+
394
+ // Remove any existing extension from the filename
395
+ let filenameWithoutExtension = baseFilename.replacingOccurrences(
396
+ of: "\\.[^\\.]+$",
397
+ with: "",
398
+ options: .regularExpression
399
+ )
400
+
401
+ // Choose extension based on whether this is a compressed file
402
+ let fileExtension: String
403
+ if isCompressed {
404
+ fileExtension = recordingSettings?.compressedFormat.lowercased() ?? "aac"
405
+ } else {
406
+ fileExtension = "wav"
407
+ }
408
+
409
+ let fullFilename = "\(filenameWithoutExtension).\(fileExtension)"
410
+ Logger.debug("Full filename: \(fullFilename)")
411
+
412
+ let fileURL = baseDirectory.appendingPathComponent(fullFilename)
413
+ Logger.debug("Final file URL: \(fileURL.path)")
414
+
415
+ // Check if file already exists
416
+ if fileManager.fileExists(atPath: fileURL.path) {
417
+ Logger.debug("File already exists at: \(fileURL.path)")
418
+ return nil
419
+ }
388
420
 
389
421
  if !fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil) {
390
422
  Logger.debug("Failed to create file at: \(fileURL.path)")
@@ -479,6 +511,20 @@ class AudioStreamManager: NSObject {
479
511
  /// - intervalMilliseconds: The interval in milliseconds for emitting audio data.
480
512
  /// - Returns: A StartRecordingResult object if recording starts successfully, or nil otherwise.
481
513
  func startRecording(settings: RecordingSettings, intervalMilliseconds: Int) -> StartRecordingResult? {
514
+ // Check for active call first
515
+ let callCenter = CXCallObserver()
516
+ if callCenter.calls.contains(where: { $0.hasEnded == false }) {
517
+ Logger.debug("Cannot start recording during an active call")
518
+ delegate?.audioStreamManager(self, didFailWithError: "Cannot start recording during an active call")
519
+ return nil
520
+ }
521
+
522
+ // Store settings first before doing anything else
523
+ recordingSettings = settings
524
+
525
+ // Add debug logging to verify settings
526
+ Logger.debug("Starting recording with settings - filename: \(settings.filename ?? "nil"), directory: \(settings.outputDirectory ?? "nil")")
527
+
482
528
  // Update auto-resume preference from settings
483
529
  autoResumeAfterInterruption = settings.autoResumeAfterInterruption
484
530
 
@@ -606,37 +652,26 @@ class AudioStreamManager: NSObject {
606
652
 
607
653
  Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
608
654
 
609
- let tempDirectory = FileManager.default.temporaryDirectory
610
- try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
655
+ // Use createRecordingFile for consistency in file handling
656
+ compressedFileURL = createRecordingFile(isCompressed: true)
611
657
 
612
- // Use the same UUID as the main recording
613
- if let recordingUUID = recordingUUID {
614
- compressedFileURL = tempDirectory.appendingPathComponent(recordingUUID.uuidString)
615
- .appendingPathExtension(settings.compressedFormat)
658
+ if let url = compressedFileURL {
659
+ Logger.debug("Using compressed file URL: \(url.path)")
616
660
 
617
- if let url = compressedFileURL {
618
- // Create empty file first
619
- if FileManager.default.createFile(atPath: url.path, contents: nil) {
620
- Logger.debug("Created empty file at: \(url.path)")
621
- } else {
622
- Logger.debug("Failed to create empty file at: \(url.path)")
623
- }
661
+ // Initialize recorder
662
+ compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
663
+ if let recorder = compressedRecorder {
664
+ let prepared = recorder.prepareToRecord()
665
+ Logger.debug("Recorder prepared: \(prepared)")
624
666
 
625
- // Then initialize recorder
626
- compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
627
- if let recorder = compressedRecorder {
628
- let prepared = recorder.prepareToRecord()
629
- Logger.debug("Recorder prepared: \(prepared)")
630
-
631
- let started = recorder.record()
632
- Logger.debug("Recorder started: \(started)")
633
-
634
- Logger.debug("Recorder current time: \(recorder.currentTime)")
635
-
636
- compressedFormat = settings.compressedFormat
637
- compressedBitRate = settings.compressedBitRate
638
- Logger.debug("Compressed recording initialized - Format: \(compressedFormat), Bitrate: \(compressedBitRate)")
639
- }
667
+ let started = recorder.record()
668
+ Logger.debug("Recorder started: \(started)")
669
+
670
+ Logger.debug("Recorder current time: \(recorder.currentTime)")
671
+
672
+ compressedFormat = settings.compressedFormat
673
+ compressedBitRate = settings.compressedBitRate
674
+ Logger.debug("Compressed recording initialized - Format: \(compressedFormat), Bitrate: \(compressedBitRate)")
640
675
  }
641
676
  }
642
677
  } catch {
@@ -700,18 +735,27 @@ class AudioStreamManager: NSObject {
700
735
  isPaused = false
701
736
  Logger.debug("Debug: Recording started successfully.")
702
737
 
738
+ var compression = compressedRecorder != nil ? CompressedRecordingInfo(
739
+ compressedFileUri: compressedFileURL?.absoluteString ?? "",
740
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
741
+ bitrate: compressedBitRate,
742
+ format: compressedFormat
743
+ ) : nil
744
+
745
+ // Get the size separately since it's not part of the initializer
746
+ if let compressedPath = compressedFileURL?.path,
747
+ let attributes = try? FileManager.default.attributesOfItem(atPath: compressedPath),
748
+ let fileSize = attributes[.size] as? Int64 {
749
+ compression?.size = fileSize
750
+ }
751
+
703
752
  return StartRecordingResult(
704
753
  fileUri: recordingFileURL!.path,
705
754
  mimeType: mimeType,
706
755
  channels: settings.numberOfChannels,
707
756
  bitDepth: settings.bitDepth,
708
757
  sampleRate: settings.sampleRate,
709
- compression: settings.enableCompressedOutput && compressedFileURL != nil ? CompressedRecordingInfo(
710
- fileUri: compressedFileURL!.absoluteString,
711
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
712
- bitrate: compressedBitRate,
713
- format: compressedFormat
714
- ) : nil
758
+ compression: compression
715
759
  )
716
760
 
717
761
  } catch {
@@ -792,6 +836,14 @@ class AudioStreamManager: NSObject {
792
836
 
793
837
  /// Resumes the current audio recording.
794
838
  func resumeRecording() {
839
+ // Check for active call first
840
+ let callCenter = CXCallObserver()
841
+ if callCenter.calls.contains(where: { $0.hasEnded == false }) {
842
+ Logger.debug("Cannot resume recording during an active call")
843
+ delegate?.audioStreamManager(self, didFailWithError: "Cannot resume recording during an active call")
844
+ return
845
+ }
846
+
795
847
  guard isRecording && isPaused else { return }
796
848
 
797
849
  lastValidDuration = nil // Clear the stored duration when resuming
@@ -920,6 +972,20 @@ class AudioStreamManager: NSObject {
920
972
  // Update the WAV header with the correct file size
921
973
  updateWavHeader(fileURL: fileURL, totalDataSize: fileSize - 44)
922
974
 
975
+ var compression = compressedRecorder != nil ? CompressedRecordingInfo(
976
+ compressedFileUri: compressedFileURL?.absoluteString ?? "",
977
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
978
+ bitrate: compressedBitRate,
979
+ format: compressedFormat
980
+ ) : nil
981
+
982
+ // Get the size separately since it's not part of the initializer
983
+ if let compressedPath = compressedFileURL?.path,
984
+ let attributes = try? FileManager.default.attributesOfItem(atPath: compressedPath),
985
+ let fileSize = attributes[.size] as? Int64 {
986
+ compression?.size = fileSize
987
+ }
988
+
923
989
  let result = RecordingResult(
924
990
  fileUri: fileURL.absoluteString,
925
991
  filename: fileURL.lastPathComponent,
@@ -929,12 +995,7 @@ class AudioStreamManager: NSObject {
929
995
  channels: settings.numberOfChannels,
930
996
  bitDepth: settings.bitDepth,
931
997
  sampleRate: settings.sampleRate,
932
- compression: settings.enableCompressedOutput && compressedFileURL != nil ? CompressedRecordingInfo(
933
- fileUri: compressedFileURL!.absoluteString,
934
- mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
935
- bitrate: compressedBitRate,
936
- format: compressedFormat
937
- ) : nil
998
+ compression: compression
938
999
  )
939
1000
 
940
1001
  // Cleanup
@@ -12,4 +12,5 @@ protocol AudioStreamManagerDelegate: AnyObject {
12
12
  func audioStreamManager(_ manager: AudioStreamManager, didResumeRecording resumeTime: Date)
13
13
  func audioStreamManager(_ manager: AudioStreamManager, didUpdateNotificationState isPaused: Bool)
14
14
  func audioStreamManager(_ manager: AudioStreamManager, didReceiveInterruption info: [String: Any])
15
+ func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String)
15
16
  }
@@ -172,7 +172,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
172
172
  // Add compression info if available
173
173
  if let compression = result.compression {
174
174
  resultDict["compression"] = [
175
- "fileUri": compression.fileUri,
175
+ "compressedFileUri": compression.compressedFileUri,
176
176
  "mimeType": compression.mimeType,
177
177
  "bitrate": compression.bitrate,
178
178
  "format": compression.format
@@ -213,8 +213,7 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
213
213
  /// - promise: A promise to resolve with the recording result or reject with an error.
214
214
  AsyncFunction("stopRecording") { (promise: Promise) in
215
215
  if let recordingResult = self.streamManager.stopRecording() {
216
- // Convert RecordingResult to a dictionary
217
- let resultDict: [String: Any] = [
216
+ var resultDict: [String: Any] = [
218
217
  "fileUri": recordingResult.fileUri,
219
218
  "filename": recordingResult.filename,
220
219
  "durationMs": recordingResult.duration,
@@ -224,6 +223,18 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
224
223
  "sampleRate": recordingResult.sampleRate,
225
224
  "mimeType": recordingResult.mimeType,
226
225
  ]
226
+
227
+ // Add compression info if available
228
+ if let compression = recordingResult.compression {
229
+ resultDict["compression"] = [
230
+ "compressedFileUri": compression.compressedFileUri,
231
+ "mimeType": compression.mimeType,
232
+ "bitrate": compression.bitrate,
233
+ "format": compression.format,
234
+ "size": compression.size
235
+ ]
236
+ }
237
+
227
238
  promise.resolve(resultDict)
228
239
  } else {
229
240
  promise.reject("ERROR", "Failed to stop recording or no recording in progress.")
@@ -459,4 +470,11 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
459
470
  func audioStreamManager(_ manager: AudioStreamManager, didReceiveInterruption info: [String: Any]) {
460
471
  sendEvent(recordingInterruptedEvent, info)
461
472
  }
473
+
474
+ func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String) {
475
+ // Send error event to JavaScript
476
+ sendEvent("error", [
477
+ "message": error
478
+ ])
479
+ }
462
480
  }
@@ -18,10 +18,11 @@ struct IOSNotificationConfig {
18
18
  }
19
19
 
20
20
  struct CompressedRecordingInfo {
21
- var fileUri: String
21
+ var compressedFileUri: String
22
22
  var mimeType: String
23
23
  var bitrate: Int
24
24
  var format: String
25
+ var size: Int64 = 0 // Add size with default value
25
26
 
26
27
  static func validate(format: String, bitrate: Int) -> Result<(String, Int), Error> {
27
28
  // Validate format
@@ -59,6 +60,7 @@ struct IOSConfig {
59
60
  enum RecordingError: Error {
60
61
  case unsupportedFormat(String)
61
62
  case invalidBitrate(Int)
63
+ case invalidOutputDirectory(String)
62
64
 
63
65
  var localizedDescription: String {
64
66
  switch self {
@@ -66,6 +68,8 @@ enum RecordingError: Error {
66
68
  return "Unsupported compression format: \(format). iOS only supports AAC."
67
69
  case .invalidBitrate(let bitrate):
68
70
  return "Invalid bitrate: \(bitrate). Must be between 8000 and 960000 bps."
71
+ case .invalidOutputDirectory(let directory):
72
+ return "Invalid output directory: \(directory). Directory does not exist, is not a directory, or is not writable."
69
73
  }
70
74
  }
71
75
  }
@@ -100,6 +104,10 @@ struct RecordingSettings {
100
104
 
101
105
  let autoResumeAfterInterruption: Bool
102
106
 
107
+ // Make these optional with nil default values
108
+ var outputDirectory: String? = nil
109
+ var filename: String? = nil
110
+
103
111
  static func fromDictionary(_ dict: [String: Any]) -> Result<RecordingSettings, Error> {
104
112
  // Extract compression settings
105
113
  let compression = dict["compression"] as? [String: Any]
@@ -222,6 +230,34 @@ struct RecordingSettings {
222
230
  settings.notification = notificationConfig
223
231
  }
224
232
 
233
+ // Parse output settings (they remain nil if not provided)
234
+ if let directory = dict["outputDirectory"] as? String {
235
+ // Only validate if a custom directory is provided
236
+ let fileManager = FileManager.default
237
+ var isDirectory: ObjCBool = false
238
+
239
+ // Clean up the directory path by removing file:// protocol if present
240
+ let cleanDirectory = directory.replacingOccurrences(of: "file://", with: "")
241
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
242
+ .replacingOccurrences(of: "//", with: "/")
243
+
244
+ if !fileManager.fileExists(atPath: cleanDirectory, isDirectory: &isDirectory) {
245
+ return .failure(RecordingError.invalidOutputDirectory("Directory does not exist: \(cleanDirectory)"))
246
+ }
247
+
248
+ if !isDirectory.boolValue {
249
+ return .failure(RecordingError.invalidOutputDirectory("Path is not a directory: \(cleanDirectory)"))
250
+ }
251
+
252
+ if !fileManager.isWritableFile(atPath: cleanDirectory) {
253
+ return .failure(RecordingError.invalidOutputDirectory("Directory is not writable: \(cleanDirectory)"))
254
+ }
255
+
256
+ settings.outputDirectory = cleanDirectory
257
+ }
258
+
259
+ settings.filename = dict["filename"] as? String
260
+
225
261
  return .success(settings)
226
262
  }
227
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-stream",
3
- "version": "1.11.6",
3
+ "version": "1.12.1",
4
4
  "description": "stream audio crossplatform",
5
5
  "license": "MIT",
6
6
  "main": "build/index.js",
@@ -11,6 +11,7 @@ export interface CompressionInfo {
11
11
  mimeType: string
12
12
  bitrate: number
13
13
  format: string
14
+ compressedFileUri?: string
14
15
  }
15
16
 
16
17
  export interface AudioStreamStatus {
@@ -188,6 +189,10 @@ export interface RecordingConfig {
188
189
 
189
190
  // Optional callback to handle recording interruptions
190
191
  onRecordingInterrupted?: (_: RecordingInterruptionEvent) => void
192
+
193
+ // Optional output configuration
194
+ outputDirectory?: string // If not provided, uses default app directory
195
+ filename?: string // If not provided, uses UUID
191
196
  }
192
197
 
193
198
  export interface NotificationConfig {
@@ -50,7 +50,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
50
50
  lastEmittedTime: number
51
51
  lastEmittedCompressionSize: number
52
52
  streamUuid: string | null
53
- extension: 'webm' | 'wav' = 'wav' // Default extension is 'webm'
53
+ extension: 'webm' | 'wav' = 'wav' // Default extension is 'wav'
54
54
  recordingConfig?: RecordingConfig
55
55
  bitDepth: BitDepth // Bit depth of the audio
56
56
  audioWorkletUrl: string
@@ -108,7 +108,9 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
108
108
  }
109
109
 
110
110
  // Start recording with options
111
- async startRecording(recordingConfig: RecordingConfig = {}) {
111
+ async startRecording(
112
+ recordingConfig: RecordingConfig = {}
113
+ ): Promise<StartRecordingResult> {
112
114
  if (this.isRecording) {
113
115
  throw new Error('Recording is already in progress')
114
116
  }
@@ -170,7 +172,15 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
170
172
  this.lastEmittedSize = 0
171
173
  this.lastEmittedTime = 0
172
174
  this.lastEmittedCompressionSize = 0
173
- this.streamUuid = Date.now().toString()
175
+
176
+ // Use custom filename if provided, otherwise fallback to timestamp
177
+ if (recordingConfig.filename) {
178
+ // Remove any existing extension from the filename
179
+ this.streamUuid = recordingConfig.filename.replace(/\.[^/.]+$/, '')
180
+ } else {
181
+ this.streamUuid = Date.now().toString()
182
+ }
183
+
174
184
  const fileUri = `${this.streamUuid}.${this.extension}`
175
185
  const streamConfig: StartRecordingResult = {
176
186
  fileUri,
@@ -266,9 +276,11 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
266
276
  }
267
277
  )
268
278
 
269
- return {
279
+ // Use the stored streamUuid (which contains our custom filename) for the final filename
280
+ const filename = `${this.streamUuid}.${this.extension}`
281
+ const result: AudioRecording = {
270
282
  fileUri,
271
- filename: `${this.streamUuid}.${this.extension}`,
283
+ filename, // This will now use our custom filename
272
284
  bitDepth: this.bitDepth,
273
285
  channels: this.recordingConfig?.channels ?? 1,
274
286
  sampleRate: this.recordingConfig?.sampleRate ?? 44100,
@@ -277,6 +289,11 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
277
289
  mimeType,
278
290
  compression,
279
291
  }
292
+
293
+ // Reset after creating the result
294
+ this.streamUuid = null
295
+
296
+ return result
280
297
  } catch (error) {
281
298
  this.logger?.error('[Stop] Error stopping recording:', error)
282
299
  throw error
@@ -325,6 +342,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
325
342
  format: this.recordingConfig.compression.format ?? 'opus',
326
343
  bitrate:
327
344
  this.recordingConfig.compression.bitrate ?? 128000,
345
+ compressedFileUri: `${this.streamUuid}.webm`,
328
346
  }
329
347
  : undefined,
330
348
  }
@@ -351,6 +351,10 @@ export class WebRecorder {
351
351
  return { pcmData: new Float32Array() }
352
352
  } finally {
353
353
  this.cleanup()
354
+ // Reset the chunks array
355
+ this.compressedChunks = []
356
+ this.compressedSize = 0
357
+ this.pendingCompressedChunk = null
354
358
  }
355
359
  }
356
360
 
@@ -378,18 +378,13 @@ export function useAudioRecorder({
378
378
  )
379
379
 
380
380
  // Check and update recording state
381
- if (
382
- status.isRecording !== state.isRecording ||
383
- status.isPaused !== state.isPaused
384
- ) {
385
- dispatch({
386
- type: 'UPDATE_RECORDING_STATE',
387
- payload: {
388
- isRecording: status.isRecording,
389
- isPaused: status.isPaused,
390
- },
391
- })
392
- }
381
+ dispatch({
382
+ type: 'UPDATE_RECORDING_STATE',
383
+ payload: {
384
+ isRecording: status.isRecording,
385
+ isPaused: status.isPaused,
386
+ },
387
+ })
393
388
 
394
389
  // Check and update recording progress
395
390
  if (