@siteed/expo-audio-stream 1.11.5 → 1.12.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.
@@ -380,11 +380,51 @@ class AudioStreamManager: NSObject {
380
380
 
381
381
  /// Creates a new recording file.
382
382
  /// - 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)
383
+ private func createRecordingFile(isCompressed: Bool = false) -> URL? {
384
+ // Add debug logging
385
+ Logger.debug("Creating recording file - settings filename: \(recordingSettings?.filename ?? "nil")")
386
+
387
+ // Get base directory - use default if no custom directory provided
388
+ let baseDirectory: URL
389
+ if let customDir = recordingSettings?.outputDirectory {
390
+ baseDirectory = URL(fileURLWithPath: customDir)
391
+ Logger.debug("Using custom directory: \(customDir)")
392
+ } else {
393
+ // Use existing default behavior
394
+ baseDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
395
+ Logger.debug("Using default directory: \(baseDirectory.path)")
396
+ }
397
+
398
+ // Use custom filename if provided, otherwise generate UUID
399
+ let baseFilename = recordingSettings?.filename ?? UUID().uuidString
400
+ Logger.debug("Using base filename: \(baseFilename)")
401
+
402
+ // Remove any existing extension from the filename
403
+ let filenameWithoutExtension = baseFilename.replacingOccurrences(
404
+ of: "\\.[^\\.]+$",
405
+ with: "",
406
+ options: .regularExpression
407
+ )
408
+
409
+ // Choose extension based on whether this is a compressed file
410
+ let fileExtension: String
411
+ if isCompressed {
412
+ fileExtension = recordingSettings?.compressedFormat.lowercased() ?? "aac"
413
+ } else {
414
+ fileExtension = "wav"
415
+ }
416
+
417
+ let fullFilename = "\(filenameWithoutExtension).\(fileExtension)"
418
+ Logger.debug("Full filename: \(fullFilename)")
419
+
420
+ let fileURL = baseDirectory.appendingPathComponent(fullFilename)
421
+ Logger.debug("Final file URL: \(fileURL.path)")
422
+
423
+ // Check if file already exists
424
+ if fileManager.fileExists(atPath: fileURL.path) {
425
+ Logger.debug("File already exists at: \(fileURL.path)")
426
+ return nil
427
+ }
388
428
 
389
429
  if !fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil) {
390
430
  Logger.debug("Failed to create file at: \(fileURL.path)")
@@ -479,6 +519,20 @@ class AudioStreamManager: NSObject {
479
519
  /// - intervalMilliseconds: The interval in milliseconds for emitting audio data.
480
520
  /// - Returns: A StartRecordingResult object if recording starts successfully, or nil otherwise.
481
521
  func startRecording(settings: RecordingSettings, intervalMilliseconds: Int) -> StartRecordingResult? {
522
+ // Check for active call first
523
+ let callCenter = CXCallObserver()
524
+ if callCenter.calls.contains(where: { $0.hasEnded == false }) {
525
+ Logger.debug("Cannot start recording during an active call")
526
+ delegate?.audioStreamManager(self, didFailWithError: "Cannot start recording during an active call")
527
+ return nil
528
+ }
529
+
530
+ // Store settings first before doing anything else
531
+ recordingSettings = settings
532
+
533
+ // Add debug logging to verify settings
534
+ Logger.debug("Starting recording with settings - filename: \(settings.filename ?? "nil"), directory: \(settings.outputDirectory ?? "nil")")
535
+
482
536
  // Update auto-resume preference from settings
483
537
  autoResumeAfterInterruption = settings.autoResumeAfterInterruption
484
538
 
@@ -606,37 +660,26 @@ class AudioStreamManager: NSObject {
606
660
 
607
661
  Logger.debug("Initializing compressed recording with settings: \(compressedSettings)")
608
662
 
609
- let tempDirectory = FileManager.default.temporaryDirectory
610
- try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
663
+ // Use createRecordingFile for consistency in file handling
664
+ compressedFileURL = createRecordingFile(isCompressed: true)
611
665
 
612
- // Use the same UUID as the main recording
613
- if let recordingUUID = recordingUUID {
614
- compressedFileURL = tempDirectory.appendingPathComponent(recordingUUID.uuidString)
615
- .appendingPathExtension(settings.compressedFormat)
666
+ if let url = compressedFileURL {
667
+ Logger.debug("Using compressed file URL: \(url.path)")
616
668
 
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
- }
669
+ // Initialize recorder
670
+ compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
671
+ if let recorder = compressedRecorder {
672
+ let prepared = recorder.prepareToRecord()
673
+ Logger.debug("Recorder prepared: \(prepared)")
624
674
 
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
- }
675
+ let started = recorder.record()
676
+ Logger.debug("Recorder started: \(started)")
677
+
678
+ Logger.debug("Recorder current time: \(recorder.currentTime)")
679
+
680
+ compressedFormat = settings.compressedFormat
681
+ compressedBitRate = settings.compressedBitRate
682
+ Logger.debug("Compressed recording initialized - Format: \(compressedFormat), Bitrate: \(compressedBitRate)")
640
683
  }
641
684
  }
642
685
  } catch {
@@ -700,18 +743,27 @@ class AudioStreamManager: NSObject {
700
743
  isPaused = false
701
744
  Logger.debug("Debug: Recording started successfully.")
702
745
 
746
+ var compression = compressedRecorder != nil ? CompressedRecordingInfo(
747
+ fileUri: compressedFileURL?.absoluteString ?? "",
748
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
749
+ bitrate: compressedBitRate,
750
+ format: compressedFormat
751
+ ) : nil
752
+
753
+ // Get the size separately since it's not part of the initializer
754
+ if let compressedPath = compressedFileURL?.path,
755
+ let attributes = try? FileManager.default.attributesOfItem(atPath: compressedPath),
756
+ let fileSize = attributes[.size] as? Int64 {
757
+ compression?.size = fileSize
758
+ }
759
+
703
760
  return StartRecordingResult(
704
761
  fileUri: recordingFileURL!.path,
705
762
  mimeType: mimeType,
706
763
  channels: settings.numberOfChannels,
707
764
  bitDepth: settings.bitDepth,
708
765
  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
766
+ compression: compression
715
767
  )
716
768
 
717
769
  } catch {
@@ -792,6 +844,14 @@ class AudioStreamManager: NSObject {
792
844
 
793
845
  /// Resumes the current audio recording.
794
846
  func resumeRecording() {
847
+ // Check for active call first
848
+ let callCenter = CXCallObserver()
849
+ if callCenter.calls.contains(where: { $0.hasEnded == false }) {
850
+ Logger.debug("Cannot resume recording during an active call")
851
+ delegate?.audioStreamManager(self, didFailWithError: "Cannot resume recording during an active call")
852
+ return
853
+ }
854
+
795
855
  guard isRecording && isPaused else { return }
796
856
 
797
857
  lastValidDuration = nil // Clear the stored duration when resuming
@@ -920,6 +980,20 @@ class AudioStreamManager: NSObject {
920
980
  // Update the WAV header with the correct file size
921
981
  updateWavHeader(fileURL: fileURL, totalDataSize: fileSize - 44)
922
982
 
983
+ var compression = compressedRecorder != nil ? CompressedRecordingInfo(
984
+ fileUri: compressedFileURL?.absoluteString ?? "",
985
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
986
+ bitrate: compressedBitRate,
987
+ format: compressedFormat
988
+ ) : nil
989
+
990
+ // Get the size separately since it's not part of the initializer
991
+ if let compressedPath = compressedFileURL?.path,
992
+ let attributes = try? FileManager.default.attributesOfItem(atPath: compressedPath),
993
+ let fileSize = attributes[.size] as? Int64 {
994
+ compression?.size = fileSize
995
+ }
996
+
923
997
  let result = RecordingResult(
924
998
  fileUri: fileURL.absoluteString,
925
999
  filename: fileURL.lastPathComponent,
@@ -929,12 +1003,7 @@ class AudioStreamManager: NSObject {
929
1003
  channels: settings.numberOfChannels,
930
1004
  bitDepth: settings.bitDepth,
931
1005
  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
1006
+ compression: compression
938
1007
  )
939
1008
 
940
1009
  // 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
  }
@@ -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
+ "fileUri": compression.fileUri,
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
  }
@@ -22,6 +22,7 @@ struct CompressedRecordingInfo {
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.5",
3
+ "version": "1.12.0",
4
4
  "description": "stream audio crossplatform",
5
5
  "license": "MIT",
6
6
  "main": "build/index.js",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@expo/config-plugins": "~9.0.0",
70
- "@siteed/publisher": "^0.4.15",
70
+ "@siteed/publisher": "^0.4.18",
71
71
  "@size-limit/preset-big-lib": "^11.1.4",
72
72
  "@types/jest": "^29.5.12",
73
73
  "@types/node": "^20.12.7",
@@ -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