@siteed/expo-audio-studio 2.9.0 → 2.10.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/android/build.gradle +9 -0
  3. package/android/src/androidTest/assets/chorus.wav +0 -0
  4. package/android/src/androidTest/assets/jfk.wav +0 -0
  5. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  6. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  7. package/android/src/androidTest/java/net/siteed/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
  8. package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
  9. package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
  10. package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
  11. package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
  12. package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
  13. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
  14. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -13
  15. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
  16. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
  17. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
  18. package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
  19. package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
  20. package/android/src/test/resources/chorus.wav +0 -0
  21. package/android/src/test/resources/generate_test_audio.py +94 -0
  22. package/android/src/test/resources/jfk.wav +0 -0
  23. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  24. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  25. package/build/cjs/ExpoAudioStream.types.js.map +1 -1
  26. package/build/cjs/ExpoAudioStream.web.js +37 -34
  27. package/build/cjs/ExpoAudioStream.web.js.map +1 -1
  28. package/build/cjs/WebRecorder.web.js +12 -10
  29. package/build/cjs/WebRecorder.web.js.map +1 -1
  30. package/build/esm/ExpoAudioStream.types.js.map +1 -1
  31. package/build/esm/ExpoAudioStream.web.js +37 -34
  32. package/build/esm/ExpoAudioStream.web.js.map +1 -1
  33. package/build/esm/WebRecorder.web.js +12 -10
  34. package/build/esm/WebRecorder.web.js.map +1 -1
  35. package/build/types/ExpoAudioStream.types.d.ts +54 -22
  36. package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
  37. package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
  38. package/build/types/WebRecorder.web.d.ts.map +1 -1
  39. package/ios/AudioNotificationManager.swift +2 -6
  40. package/ios/AudioStreamManager.swift +116 -50
  41. package/ios/ExpoAudioStream.podspec +6 -0
  42. package/ios/ExpoAudioStreamModule.swift +11 -8
  43. package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
  44. package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
  45. package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
  46. package/ios/ExpoAudioStudioTests/Info.plist +22 -0
  47. package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
  48. package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
  49. package/ios/RecordingSettings.swift +53 -22
  50. package/ios/tests/integration/buffer_duration_test.swift +185 -0
  51. package/ios/tests/integration/output_control_test.swift +322 -0
  52. package/ios/tests/integration/run_integration_tests.sh +27 -0
  53. package/ios/tests/standalone/audio_processing_test.swift +144 -0
  54. package/ios/tests/standalone/audio_recording_test.swift +277 -0
  55. package/ios/tests/standalone/audio_streaming_test.swift +249 -0
  56. package/ios/tests/standalone/standalone_test.swift +144 -0
  57. package/package.json +140 -133
  58. package/src/ExpoAudioStream.types.ts +66 -22
  59. package/src/ExpoAudioStream.web.ts +43 -38
  60. package/src/WebRecorder.web.ts +13 -10
  61. package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
  62. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  63. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  64. /package/plugin/build/{index.d.ts → index.d.cts} +0 -0
@@ -546,7 +546,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
546
546
  // Choose extension based on whether this is a compressed file
547
547
  let fileExtension: String
548
548
  if isCompressed {
549
- fileExtension = recordingSettings?.compressedFormat.lowercased() ?? "aac"
549
+ fileExtension = recordingSettings?.output.compressed.format.lowercased() ?? "aac"
550
550
  } else {
551
551
  fileExtension = "wav"
552
552
  }
@@ -638,7 +638,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
638
638
  }
639
639
 
640
640
  // Add compression info if enabled
641
- if settings.enableCompressedOutput,
641
+ if settings.output.compressed.enabled,
642
642
  let compressedURL = compressedFileURL,
643
643
  FileManager.default.fileExists(atPath: compressedURL.path) {
644
644
  do {
@@ -705,8 +705,19 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
705
705
  self.lastBufferTime = time
706
706
  }
707
707
 
708
+ // Calculate buffer size from duration if specified
709
+ let bufferSize: AVAudioFrameCount
710
+ if let duration = recordingSettings?.bufferDurationSeconds {
711
+ let sampleRate = inputHardwareFormat.sampleRate
712
+ let calculatedSize = AVAudioFrameCount(duration * sampleRate)
713
+ // Apply safety clamping
714
+ bufferSize = max(256, min(calculatedSize, 16384))
715
+ } else {
716
+ bufferSize = 1024 // Default
717
+ }
718
+
708
719
  // Install the tap with hardware format
709
- inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputHardwareFormat, block: tapBlock)
720
+ inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputHardwareFormat, block: tapBlock)
710
721
  Logger.debug("AudioStreamManager", "Tap installed with hardware-compatible format")
711
722
 
712
723
  // Prepare the engine if requested
@@ -768,31 +779,39 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
768
779
  lastEmittedCompressedSizeAnalysis = 0
769
780
  isPaused = false
770
781
 
771
- // Create recording file first
772
- recordingFileURL = createRecordingFile()
773
- if let url = recordingFileURL {
774
- do {
775
- // Ensure directory exists if needed (createRecordingFile should handle this, but belt-and-suspenders)
776
- try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
777
- // Create the file if it doesn't exist (createRecordingFile should also handle this)
778
- if !fileManager.fileExists(atPath: url.path) {
779
- fileManager.createFile(atPath: url.path, contents: nil, attributes: nil)
782
+ // Create recording file first (unless primary output is disabled)
783
+ if settings.output.primary.enabled {
784
+ recordingFileURL = createRecordingFile()
785
+ if let url = recordingFileURL {
786
+ do {
787
+ // Ensure directory exists if needed (createRecordingFile should handle this, but belt-and-suspenders)
788
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
789
+ // Create the file if it doesn't exist (createRecordingFile should also handle this)
790
+ if !fileManager.fileExists(atPath: url.path) {
791
+ fileManager.createFile(atPath: url.path, contents: nil, attributes: nil)
792
+ }
793
+ // Open the handle for writing
794
+ self.fileHandle = try FileHandle(forWritingTo: url)
795
+ // Write initial dummy header immediately
796
+ let header = createWavHeader(dataSize: 0)
797
+ self.fileHandle?.write(header)
798
+ self.totalDataSize = Int64(WAV_HEADER_SIZE) // Initialize size with header size
799
+ Logger.debug("AudioStreamManager", "File handle opened and initial header written for \(url.path). Initial size: \(self.totalDataSize)")
800
+ } catch {
801
+ Logger.debug("AudioStreamManager", "Error creating/opening file handle: \(error.localizedDescription)")
802
+ // No need to call cleanupPreparation here, return false will handle it
803
+ return false
780
804
  }
781
- // Open the handle for writing
782
- self.fileHandle = try FileHandle(forWritingTo: url)
783
- // Write initial dummy header immediately
784
- let header = createWavHeader(dataSize: 0)
785
- self.fileHandle?.write(header)
786
- self.totalDataSize = Int64(WAV_HEADER_SIZE) // Initialize size with header size
787
- Logger.debug("AudioStreamManager", "File handle opened and initial header written for \(url.path). Initial size: \(self.totalDataSize)")
788
- } catch {
789
- Logger.debug("AudioStreamManager", "Error creating/opening file handle: \(error.localizedDescription)")
790
- // No need to call cleanupPreparation here, return false will handle it
805
+ } else {
806
+ Logger.debug("AudioStreamManager", "Error: Failed to create recording file URL.")
791
807
  return false
792
808
  }
793
809
  } else {
794
- Logger.debug("AudioStreamManager", "Error: Failed to create recording file URL.")
795
- return false
810
+ // Skip file writing mode
811
+ recordingFileURL = nil
812
+ fileHandle = nil
813
+ totalDataSize = 0
814
+ Logger.debug("AudioStreamManager", "Skip file writing mode enabled - no file will be created")
796
815
  }
797
816
 
798
817
  var newSettings = settings
@@ -864,7 +883,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
864
883
  Logger.debug("AudioStreamManager", " - actual session sample rate: \(session.sampleRate)Hz") // Log actual rate
865
884
  Logger.debug("AudioStreamManager", " - channels: \(settings.numberOfChannels)")
866
885
  Logger.debug("AudioStreamManager", " - bit depth: \(settings.bitDepth)-bit")
867
- Logger.debug("AudioStreamManager", " - compression enabled: \(settings.enableCompressedOutput)")
886
+ Logger.debug("AudioStreamManager", " - compression enabled: \(settings.output.compressed.enabled)")
868
887
 
869
888
  // Use our shared tap installation method
870
889
  let tapFormat = installTapWithHardwareFormat()
@@ -880,13 +899,13 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
880
899
  audioEngine.prepare() // Prepare the engine without starting it
881
900
 
882
901
  // Setup compressed recording if enabled
883
- if settings.enableCompressedOutput {
902
+ if settings.output.compressed.enabled {
884
903
  // Create compressed settings
885
904
  let compressedSettings: [String: Any] = [
886
- AVFormatIDKey: settings.compressedFormat == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
905
+ AVFormatIDKey: settings.output.compressed.format == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
887
906
  AVSampleRateKey: Float64(settings.sampleRate),
888
907
  AVNumberOfChannelsKey: settings.numberOfChannels,
889
- AVEncoderBitRateKey: settings.compressedBitRate,
908
+ AVEncoderBitRateKey: settings.output.compressed.bitrate,
890
909
  AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
891
910
  AVEncoderBitDepthHintKey: settings.bitDepth
892
911
  ]
@@ -913,8 +932,8 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
913
932
  } else {
914
933
  // Note: We don't start the recorder yet, just prepare it
915
934
  Logger.debug("AudioStreamManager", "Compressed recording prepared successfully")
916
- compressedFormat = settings.compressedFormat
917
- compressedBitRate = settings.compressedBitRate
935
+ compressedFormat = settings.output.compressed.format
936
+ compressedBitRate = settings.output.compressed.bitrate
918
937
  }
919
938
  }
920
939
  } catch {
@@ -991,12 +1010,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
991
1010
  return nil
992
1011
  }
993
1012
 
994
- guard let settings = recordingSettings,
995
- let fileUri = recordingFileURL?.absoluteString else {
996
- Logger.debug("Missing settings or file URI")
1013
+ guard let settings = recordingSettings else {
1014
+ Logger.debug("Missing settings")
997
1015
  return nil
998
1016
  }
999
1017
 
1018
+ // File URI is optional when primary output is disabled
1019
+ let fileUri = recordingFileURL?.absoluteString ?? ""
1020
+
1000
1021
  do {
1001
1022
  enableWakeLock()
1002
1023
 
@@ -1437,20 +1458,26 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1437
1458
  let dataToWrite = Data(bytes: bufferData, count: Int(audioData.mDataByteSize))
1438
1459
 
1439
1460
  // --- Background File Writing ---
1440
- // Use the persistent fileHandle opened during preparation.
1441
- DispatchQueue.global(qos: .utility).async { [weak self] in
1442
- guard let self = self, let handle = self.fileHandle else {
1443
- Logger.debug("BG Write Error: File handle is nil.")
1444
- return
1445
- }
1446
- do {
1447
- try handle.seekToEnd()
1448
- try handle.write(contentsOf: dataToWrite)
1449
- // Update total size state
1450
- self.totalDataSize += Int64(dataToWrite.count)
1451
- } catch {
1452
- Logger.debug("BG Write Error: Failed to seek/write: \(error.localizedDescription)")
1461
+ // Only write to file if primary output is enabled
1462
+ if settings.output.primary.enabled {
1463
+ // Use the persistent fileHandle opened during preparation.
1464
+ DispatchQueue.global(qos: .utility).async { [weak self] in
1465
+ guard let self = self, let handle = self.fileHandle else {
1466
+ Logger.debug("BG Write Error: File handle is nil.")
1467
+ return
1468
+ }
1469
+ do {
1470
+ try handle.seekToEnd()
1471
+ try handle.write(contentsOf: dataToWrite)
1472
+ // Update total size state
1473
+ self.totalDataSize += Int64(dataToWrite.count)
1474
+ } catch {
1475
+ Logger.debug("BG Write Error: Failed to seek/write: \(error.localizedDescription)")
1476
+ }
1453
1477
  }
1478
+ } else {
1479
+ // Still track total size for statistics even without file writing
1480
+ self.totalDataSize += Int64(dataToWrite.count)
1454
1481
  }
1455
1482
 
1456
1483
  // --- Event Emission & Analysis ---
@@ -1708,9 +1735,48 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1708
1735
  // Reset audio engine
1709
1736
  audioEngine.reset()
1710
1737
 
1711
- guard let fileURL = recordingFileURL,
1712
- let settings = recordingSettings else {
1713
- Logger.debug("Recording or file URL is nil.")
1738
+ guard let settings = recordingSettings else {
1739
+ Logger.debug("Recording settings is nil.")
1740
+ stopping = false // Reset stopping flag before returning nil
1741
+ return nil
1742
+ }
1743
+
1744
+ // For streaming-only mode (no primary output), create a result without file validation
1745
+ if !settings.output.primary.enabled {
1746
+ let durationMs = Int64(finalDuration * 1000)
1747
+ let result = RecordingResult(
1748
+ fileUri: "",
1749
+ filename: "stream-only",
1750
+ mimeType: mimeType,
1751
+ duration: durationMs,
1752
+ size: totalDataSize,
1753
+ channels: settings.numberOfChannels,
1754
+ bitDepth: settings.bitDepth,
1755
+ sampleRate: settings.sampleRate,
1756
+ compression: nil
1757
+ )
1758
+
1759
+ // Cleanup
1760
+ recordingSettings = nil
1761
+ startTime = nil
1762
+ totalPausedDuration = 0
1763
+ currentPauseStart = nil
1764
+ lastEmissionTime = nil
1765
+ lastEmissionTimeAnalysis = nil
1766
+ lastEmittedSize = 0
1767
+ lastEmittedSizeAnalysis = 0
1768
+ lastEmittedCompressedSize = 0
1769
+ accumulatedData.removeAll()
1770
+ accumulatedAnalysisData.removeAll()
1771
+ recordingUUID = nil
1772
+ totalDataSize = 0
1773
+
1774
+ stopping = false
1775
+ return result
1776
+ }
1777
+
1778
+ guard let fileURL = recordingFileURL else {
1779
+ Logger.debug("Recording file URL is nil.")
1714
1780
  stopping = false // Reset stopping flag before returning nil
1715
1781
  return nil
1716
1782
  }
@@ -1899,7 +1965,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1899
1965
 
1900
1966
  // Get the string value from settings using the correct property name
1901
1967
  // The property in RecordingSettings likely matches the TS interface: deviceDisconnectionBehavior
1902
- let behaviorString = settings.deviceDisconnectionBehavior ?? "pause" // Use the correct property name
1968
+ let behaviorString = settings.deviceDisconnectionBehavior.rawValue // Get the raw value from the enum
1903
1969
  let behavior = DeviceDisconnectionBehavior(rawValue: behaviorString) ?? .PAUSE // Convert to enum, default to .PAUSE
1904
1970
 
1905
1971
  Logger.debug("Recording device disconnected! Applying behavior: \(behavior.rawValue)")
@@ -24,4 +24,10 @@ Pod::Spec.new do |s|
24
24
  }
25
25
 
26
26
  s.source_files = "**/*.{h,m,mm,swift}"
27
+ s.exclude_files = [
28
+ "*_test.swift",
29
+ "standalone_test.swift",
30
+ "tests/**/*",
31
+ "ExpoAudioStudioTests/**/*"
32
+ ]
27
33
  end
@@ -166,19 +166,22 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
166
166
  return
167
167
  }
168
168
 
169
- // Check if compression is enabled and format is Opus
169
+ // Check if output.compressed is enabled and format is Opus
170
170
  var modifiedOptions = options
171
- if let compression = options["compression"] as? [String: Any],
172
- let enabled = compression["enabled"] as? Bool, enabled,
173
- let format = compression["format"] as? String,
171
+ if let output = options["output"] as? [String: Any],
172
+ let compressed = output["compressed"] as? [String: Any],
173
+ let enabled = compressed["enabled"] as? Bool, enabled,
174
+ let format = compressed["format"] as? String,
174
175
  format.lowercased() == "opus" {
175
176
 
176
- // Create a mutable copy of the compression dictionary
177
- var modifiedCompression = compression
177
+ // Create mutable copies
178
+ var modifiedOutput = output
179
+ var modifiedCompressed = compressed
178
180
 
179
181
  // Change format to AAC and log warning
180
- modifiedCompression["format"] = "aac"
181
- modifiedOptions["compression"] = modifiedCompression
182
+ modifiedCompressed["format"] = "aac"
183
+ modifiedOutput["compressed"] = modifiedCompressed
184
+ modifiedOptions["output"] = modifiedOutput
182
185
 
183
186
  Logger.warn("ExpoAudioStreamModule", "startRecording: Opus format is not supported on iOS. Falling back to AAC format.")
184
187
  }
@@ -0,0 +1,338 @@
1
+ import XCTest
2
+ import AVFoundation
3
+ @testable import ExpoAudioStream
4
+
5
+ class AudioFileHandlerTests: XCTestCase {
6
+
7
+ var tempDir: URL!
8
+ var audioProcessor: AudioProcessor!
9
+
10
+ // Helper function to create WAV header for tests
11
+ private func createWavHeader(sampleRate: Int, channels: Int, bitsPerSample: Int, dataSize: Int) -> Data {
12
+ var header = Data()
13
+
14
+ let blockAlign = channels * (bitsPerSample / 8)
15
+ let byteRate = sampleRate * blockAlign
16
+
17
+ // "RIFF" chunk descriptor
18
+ header.append(contentsOf: "RIFF".utf8)
19
+ header.append(contentsOf: UInt32(36 + dataSize).littleEndianBytes)
20
+ header.append(contentsOf: "WAVE".utf8)
21
+
22
+ // "fmt " sub-chunk
23
+ header.append(contentsOf: "fmt ".utf8)
24
+ header.append(contentsOf: UInt32(16).littleEndianBytes) // PCM format requires 16 bytes for the fmt sub-chunk
25
+ header.append(contentsOf: UInt16(1).littleEndianBytes) // Audio format 1 for PCM
26
+ header.append(contentsOf: UInt16(channels).littleEndianBytes)
27
+ header.append(contentsOf: UInt32(sampleRate).littleEndianBytes)
28
+ header.append(contentsOf: UInt32(byteRate).littleEndianBytes) // byteRate
29
+ header.append(contentsOf: UInt16(blockAlign).littleEndianBytes) // blockAlign
30
+ header.append(contentsOf: UInt16(bitsPerSample).littleEndianBytes) // bits per sample
31
+
32
+ // "data" sub-chunk
33
+ header.append(contentsOf: "data".utf8)
34
+ header.append(contentsOf: UInt32(dataSize).littleEndianBytes) // Sub-chunk data size
35
+
36
+ return header
37
+ }
38
+
39
+ // Helper function to update WAV header for tests
40
+ private func updateWavHeader(fileURL: URL) {
41
+ guard let fileHandle = try? FileHandle(forUpdating: fileURL) else { return }
42
+ defer { fileHandle.closeFile() }
43
+
44
+ // Get file size
45
+ fileHandle.seekToEndOfFile()
46
+ let fileSize = fileHandle.offsetInFile
47
+
48
+ // Update file size in header (bytes 4-7)
49
+ fileHandle.seek(toFileOffset: 4)
50
+ var size = UInt32(fileSize - 8).littleEndian
51
+ fileHandle.write(Data(bytes: &size, count: 4))
52
+
53
+ // Update data chunk size (bytes 40-43)
54
+ fileHandle.seek(toFileOffset: 40)
55
+ var dataSize = UInt32(fileSize - 44).littleEndian
56
+ fileHandle.write(Data(bytes: &dataSize, count: 4))
57
+ }
58
+ }
59
+
60
+ override func setUp() {
61
+ super.setUp()
62
+
63
+ // Create temporary directory for tests
64
+ tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
65
+ try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
66
+
67
+ // Initialize AudioProcessor
68
+ audioProcessor = AudioProcessor(filesDir: tempDir)
69
+ }
70
+
71
+ override func tearDown() {
72
+ // Clean up temporary directory
73
+ try? FileManager.default.removeItem(at: tempDir)
74
+
75
+ super.tearDown()
76
+ }
77
+
78
+ // MARK: - WAV Header Tests
79
+
80
+ func testWriteWavHeader_createsValidHeader() {
81
+ // Given
82
+ let sampleRate = 44100
83
+ let channels = 2
84
+ let bitsPerSample = 16
85
+ let dataSize = 1000
86
+
87
+ // When
88
+ let header = createWavHeader(
89
+ sampleRate: sampleRate,
90
+ channels: channels,
91
+ bitsPerSample: bitsPerSample,
92
+ dataSize: dataSize
93
+ )
94
+
95
+ // Then
96
+ XCTAssertEqual(header.count, 44, "WAV header should be 44 bytes")
97
+
98
+ // Verify RIFF header
99
+ let riffString = String(data: header[0..<4], encoding: .ascii)
100
+ XCTAssertEqual(riffString, "RIFF")
101
+
102
+ // Verify WAVE format
103
+ let waveString = String(data: header[8..<12], encoding: .ascii)
104
+ XCTAssertEqual(waveString, "WAVE")
105
+
106
+ // Verify fmt chunk
107
+ let fmtString = String(data: header[12..<16], encoding: .ascii)
108
+ XCTAssertEqual(fmtString, "fmt ")
109
+
110
+ // Verify data chunk
111
+ let dataString = String(data: header[36..<40], encoding: .ascii)
112
+ XCTAssertEqual(dataString, "data")
113
+ }
114
+
115
+ func testCreateAudioFile_createsFileWithCorrectSize() {
116
+ // Given
117
+ let fileName = "test_audio.wav"
118
+ let sampleRate = 16000
119
+ let channels = 1
120
+ let durationMs = 1000
121
+
122
+ // When
123
+ let result = audioProcessor.createAudioFile(
124
+ fileName: fileName,
125
+ sampleRate: sampleRate,
126
+ channels: channels,
127
+ durationMs: durationMs
128
+ )
129
+
130
+ // Then
131
+ XCTAssertTrue(result["success"] as? Bool ?? false)
132
+
133
+ let fileUri = result["fileUri"] as? String
134
+ XCTAssertNotNil(fileUri)
135
+
136
+ if let fileUri = fileUri {
137
+ let fileURL = URL(string: fileUri)!
138
+ let fileExists = FileManager.default.fileExists(atPath: fileURL.path)
139
+ XCTAssertTrue(fileExists)
140
+
141
+ // Verify file size
142
+ if let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
143
+ let fileSize = attributes[.size] as? Int64 {
144
+ let expectedDataSize = sampleRate * channels * 2 * durationMs / 1000
145
+ let expectedFileSize = 44 + expectedDataSize // WAV header + data
146
+ XCTAssertEqual(fileSize, Int64(expectedFileSize))
147
+ }
148
+ }
149
+ }
150
+
151
+ func testDeleteAudioFile_removesExistingFile() {
152
+ // Given - Create a file first
153
+ let fileName = "test_to_delete.wav"
154
+ _ = audioProcessor.createAudioFile(
155
+ fileName: fileName,
156
+ sampleRate: 16000,
157
+ channels: 1,
158
+ durationMs: 100
159
+ )
160
+
161
+ // When
162
+ let result = audioProcessor.deleteAudioFile(fileName: fileName)
163
+
164
+ // Then
165
+ XCTAssertTrue(result["success"] as? Bool ?? false)
166
+
167
+ let fileURL = tempDir.appendingPathComponent(fileName)
168
+ XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
169
+ }
170
+
171
+ func testDeleteAudioFile_handlesNonExistentFile() {
172
+ // Given
173
+ let fileName = "non_existent.wav"
174
+
175
+ // When
176
+ let result = audioProcessor.deleteAudioFile(fileName: fileName)
177
+
178
+ // Then
179
+ XCTAssertFalse(result["success"] as? Bool ?? true)
180
+ XCTAssertNotNil(result["error"])
181
+ }
182
+
183
+ func testGetAudioFiles_returnsCorrectList() {
184
+ // Given - Create multiple files
185
+ let fileNames = ["file1.wav", "file2.wav", "file3.txt"]
186
+
187
+ for fileName in fileNames {
188
+ if fileName.hasSuffix(".wav") {
189
+ _ = audioProcessor.createAudioFile(
190
+ fileName: fileName,
191
+ sampleRate: 16000,
192
+ channels: 1,
193
+ durationMs: 100
194
+ )
195
+ } else {
196
+ // Create non-audio file
197
+ let url = tempDir.appendingPathComponent(fileName)
198
+ try? "test".write(to: url, atomically: true, encoding: .utf8)
199
+ }
200
+ }
201
+
202
+ // When
203
+ let files = audioProcessor.getAudioFiles()
204
+
205
+ // Then
206
+ XCTAssertEqual(files.count, 2, "Should only return WAV files")
207
+ XCTAssertTrue(files.contains("file1.wav"))
208
+ XCTAssertTrue(files.contains("file2.wav"))
209
+ XCTAssertFalse(files.contains("file3.txt"))
210
+ }
211
+
212
+ func testClearAudioStorage_removesAllAudioFiles() {
213
+ // Given - Create multiple files
214
+ let audioFiles = ["audio1.wav", "audio2.wav"]
215
+ let otherFiles = ["document.txt", "image.png"]
216
+
217
+ for fileName in audioFiles {
218
+ _ = audioProcessor.createAudioFile(
219
+ fileName: fileName,
220
+ sampleRate: 16000,
221
+ channels: 1,
222
+ durationMs: 100
223
+ )
224
+ }
225
+
226
+ for fileName in otherFiles {
227
+ let url = tempDir.appendingPathComponent(fileName)
228
+ try? "test".write(to: url, atomically: true, encoding: .utf8)
229
+ }
230
+
231
+ // When
232
+ let result = audioProcessor.clearAudioStorage()
233
+
234
+ // Then
235
+ XCTAssertTrue(result["success"] as? Bool ?? false)
236
+
237
+ // Verify audio files are deleted
238
+ for fileName in audioFiles {
239
+ let url = tempDir.appendingPathComponent(fileName)
240
+ XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
241
+ }
242
+
243
+ // Verify other files remain
244
+ for fileName in otherFiles {
245
+ let url = tempDir.appendingPathComponent(fileName)
246
+ XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
247
+ }
248
+ }
249
+
250
+ func testUpdateWavHeader_updatesFileSizeCorrectly() {
251
+ // Given - Create a file
252
+ let fileName = "test_update.wav"
253
+ let createResult = audioProcessor.createAudioFile(
254
+ fileName: fileName,
255
+ sampleRate: 16000,
256
+ channels: 1,
257
+ durationMs: 1000
258
+ )
259
+
260
+ guard let fileUri = createResult["fileUri"] as? String,
261
+ let fileURL = URL(string: fileUri) else {
262
+ XCTFail("Failed to create test file")
263
+ return
264
+ }
265
+
266
+ // Simulate appending more data
267
+ if let fileHandle = try? FileHandle(forWritingTo: fileURL) {
268
+ fileHandle.seekToEndOfFile()
269
+ let additionalData = Data(count: 1000)
270
+ fileHandle.write(additionalData)
271
+ fileHandle.closeFile()
272
+ }
273
+
274
+ // When
275
+ updateWavHeader(fileURL: fileURL)
276
+
277
+ // Then - Verify header was updated
278
+ if let data = try? Data(contentsOf: fileURL) {
279
+ // Check file size in header (bytes 4-7)
280
+ let fileSize = data.subdata(in: 4..<8).withUnsafeBytes { $0.load(as: UInt32.self) }
281
+ XCTAssertEqual(fileSize, UInt32(data.count - 8))
282
+
283
+ // Check data chunk size (bytes 40-43)
284
+ let dataSize = data.subdata(in: 40..<44).withUnsafeBytes { $0.load(as: UInt32.self) }
285
+ XCTAssertEqual(dataSize, UInt32(data.count - 44))
286
+ }
287
+ }
288
+
289
+ // MARK: - Real File Tests
290
+
291
+ func testLoadRealWavFile() {
292
+ // Load test asset
293
+ guard let testBundle = Bundle(for: type(of: self)).url(forResource: "jfk", withExtension: "wav") else {
294
+ XCTFail("Test resource jfk.wav should exist")
295
+ return
296
+ }
297
+
298
+ // Copy to temp directory
299
+ let destURL = tempDir.appendingPathComponent("test_jfk.wav")
300
+ try? FileManager.default.copyItem(at: testBundle, to: destURL)
301
+
302
+ // Load and verify
303
+ let audioData = audioProcessor.loadAudioFromAnyFormat(
304
+ filePath: destURL.path,
305
+ config: nil
306
+ )
307
+
308
+ XCTAssertNotNil(audioData)
309
+
310
+ // JFK.wav is known to be mono, 16kHz, 16-bit
311
+ XCTAssertEqual(audioData?.sampleRate, 16000)
312
+ XCTAssertEqual(audioData?.channels, 1)
313
+ XCTAssertEqual(audioData?.bitDepth, 16)
314
+ XCTAssertGreaterThan(audioData?.data.count ?? 0, 0)
315
+ }
316
+
317
+ func testProcessMultipleRealFiles() {
318
+ let testFiles = ["jfk.wav", "recorder_hello_world.wav", "osr_us_000_0010_8k.wav"]
319
+
320
+ for fileName in testFiles {
321
+ guard let testBundle = Bundle(for: type(of: self)).url(forResource: fileName.replacingOccurrences(of: ".wav", with: ""), withExtension: "wav") else {
322
+ print("Skipping \(fileName) - not found in test bundle")
323
+ continue
324
+ }
325
+
326
+ let destURL = tempDir.appendingPathComponent(fileName)
327
+ try? FileManager.default.copyItem(at: testBundle, to: destURL)
328
+
329
+ let audioData = audioProcessor.loadAudioFromAnyFormat(
330
+ filePath: destURL.path,
331
+ config: nil
332
+ )
333
+
334
+ XCTAssertNotNil(audioData, "Should load \(fileName)")
335
+ XCTAssertGreaterThan(audioData?.data.count ?? 0, 0, "\(fileName) should have audio data")
336
+ }
337
+ }
338
+ }