@siteed/expo-audio-studio 2.8.6 → 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.
- package/CHANGELOG.md +17 -1
- package/android/build.gradle +9 -0
- package/android/src/androidTest/assets/chorus.wav +0 -0
- package/android/src/androidTest/assets/jfk.wav +0 -0
- package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
- package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
- package/android/src/androidTest/java/net/siteed/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
- package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -15
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
- package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
- package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +94 -0
- package/android/src/test/resources/jfk.wav +0 -0
- package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
- package/android/src/test/resources/recorder_hello_world.wav +0 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.web.js +38 -35
- package/build/cjs/ExpoAudioStream.web.js.map +1 -1
- package/build/cjs/WebRecorder.web.js +122 -102
- package/build/cjs/WebRecorder.web.js.map +1 -1
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.web.js +38 -35
- package/build/esm/ExpoAudioStream.web.js.map +1 -1
- package/build/esm/WebRecorder.web.js +122 -102
- package/build/esm/WebRecorder.web.js.map +1 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +3 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/ExpoAudioStream.types.d.ts +54 -22
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/types/WebRecorder.web.d.ts +19 -3
- package/build/types/WebRecorder.web.d.ts.map +1 -1
- package/ios/AudioNotificationManager.swift +2 -6
- package/ios/AudioStreamManager.swift +116 -50
- package/ios/ExpoAudioStream.podspec +6 -0
- package/ios/ExpoAudioStreamModule.swift +11 -8
- package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
- package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
- package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
- package/ios/ExpoAudioStudioTests/Info.plist +22 -0
- package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
- package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
- package/ios/RecordingSettings.swift +53 -22
- package/ios/tests/integration/buffer_duration_test.swift +185 -0
- package/ios/tests/integration/output_control_test.swift +322 -0
- package/ios/tests/integration/run_integration_tests.sh +27 -0
- package/ios/tests/standalone/audio_processing_test.swift +144 -0
- package/ios/tests/standalone/audio_recording_test.swift +277 -0
- package/ios/tests/standalone/audio_streaming_test.swift +249 -0
- package/ios/tests/standalone/standalone_test.swift +144 -0
- package/package.json +140 -133
- package/src/AudioAnalysis/AudioAnalysis.types.ts +8 -1
- package/src/ExpoAudioStream.types.ts +66 -22
- package/src/ExpoAudioStream.web.ts +45 -39
- package/src/WebRecorder.web.ts +164 -130
- package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
- package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
- package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
- /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?.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
fileManager.
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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.
|
|
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.
|
|
902
|
+
if settings.output.compressed.enabled {
|
|
884
903
|
// Create compressed settings
|
|
885
904
|
let compressedSettings: [String: Any] = [
|
|
886
|
-
AVFormatIDKey: settings.
|
|
905
|
+
AVFormatIDKey: settings.output.compressed.format == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
|
|
887
906
|
AVSampleRateKey: Float64(settings.sampleRate),
|
|
888
907
|
AVNumberOfChannelsKey: settings.numberOfChannels,
|
|
889
|
-
AVEncoderBitRateKey: settings.
|
|
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
|
-
|
|
917
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
|
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)")
|
|
@@ -166,19 +166,22 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
|
|
|
166
166
|
return
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
// Check if
|
|
169
|
+
// Check if output.compressed is enabled and format is Opus
|
|
170
170
|
var modifiedOptions = options
|
|
171
|
-
if let
|
|
172
|
-
let
|
|
173
|
-
let
|
|
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
|
|
177
|
-
var
|
|
177
|
+
// Create mutable copies
|
|
178
|
+
var modifiedOutput = output
|
|
179
|
+
var modifiedCompressed = compressed
|
|
178
180
|
|
|
179
181
|
// Change format to AAC and log warning
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|