@siteed/expo-audio-studio 2.10.6 → 2.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.
Files changed (23) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/android/src/androidTest/java/net/siteed/audiostream/integration/AudioFocusStrategyIntegrationTest.kt +332 -0
  3. package/android/src/androidTest/java/net/siteed/audiostream/integration/DeviceDisconnectionFallbackTest.kt +218 -0
  4. package/android/src/androidTest/java/net/siteed/audiostream/integration/EventEmissionIntervalTest.kt +120 -0
  5. package/android/src/androidTest/java/net/siteed/audiostream/integration/M4aFormatTest.kt +345 -0
  6. package/android/src/androidTest/java/net/siteed/audiostream/integration/PcmStreamingDurationTest.kt +252 -0
  7. package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +5 -0
  8. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +44 -32
  9. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +198 -22
  10. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +13 -4
  11. package/android/src/test/java/net/siteed/audiostream/AudioFocusStrategyTest.kt +249 -0
  12. package/android/src/test/java/net/siteed/audiostream/AudioFormatTest.kt +151 -0
  13. package/android/src/test/java/net/siteed/audiostream/DeviceDisconnectionFallbackUnitTest.kt +140 -0
  14. package/build/cjs/ExpoAudioStream.types.js.map +1 -1
  15. package/build/esm/ExpoAudioStream.types.js.map +1 -1
  16. package/build/types/ExpoAudioStream.types.d.ts +25 -2
  17. package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
  18. package/ios/AudioStreamManager.swift +55 -43
  19. package/ios/ExpoAudioStudioTests/EventEmissionIntervalTests.swift +105 -0
  20. package/ios/tests/README.md +41 -0
  21. package/ios/tests/opus_support_test_macos.swift +154 -0
  22. package/package.json +2 -2
  23. package/src/ExpoAudioStream.types.ts +27 -2
@@ -0,0 +1,105 @@
1
+ import XCTest
2
+ @testable import ExpoAudioStudio
3
+
4
+ class EventEmissionIntervalTests: XCTestCase {
5
+
6
+ var audioStreamManager: AudioStreamManager!
7
+
8
+ override func setUp() {
9
+ super.setUp()
10
+ audioStreamManager = AudioStreamManager()
11
+ }
12
+
13
+ override func tearDown() {
14
+ audioStreamManager = nil
15
+ super.tearDown()
16
+ }
17
+
18
+ func testIntervalClamping() {
19
+ // Test case 1: Interval below minimum (10ms)
20
+ let config1 = RecordingConfig()
21
+ config1.interval = 10
22
+ config1.intervalAnalysis = 10
23
+
24
+ audioStreamManager.prepareRecording(with: config1) { error in
25
+ XCTAssertNil(error, "Should prepare successfully")
26
+ }
27
+
28
+ // After our fix, this should be 10ms (0.01s), not 100ms (0.1s)
29
+ XCTAssertEqual(audioStreamManager.emissionInterval, 0.01, accuracy: 0.001)
30
+ XCTAssertEqual(audioStreamManager.emissionIntervalAnalysis, 0.01, accuracy: 0.001)
31
+
32
+ // Test case 2: Interval at old minimum (100ms)
33
+ let config2 = RecordingConfig()
34
+ config2.interval = 100
35
+ config2.intervalAnalysis = 100
36
+
37
+ audioStreamManager.prepareRecording(with: config2) { error in
38
+ XCTAssertNil(error, "Should prepare successfully")
39
+ }
40
+
41
+ XCTAssertEqual(audioStreamManager.emissionInterval, 0.1, accuracy: 0.001)
42
+ XCTAssertEqual(audioStreamManager.emissionIntervalAnalysis, 0.1, accuracy: 0.001)
43
+
44
+ // Test case 3: Interval above minimum (200ms)
45
+ let config3 = RecordingConfig()
46
+ config3.interval = 200
47
+ config3.intervalAnalysis = 200
48
+
49
+ audioStreamManager.prepareRecording(with: config3) { error in
50
+ XCTAssertNil(error, "Should prepare successfully")
51
+ }
52
+
53
+ XCTAssertEqual(audioStreamManager.emissionInterval, 0.2, accuracy: 0.001)
54
+ XCTAssertEqual(audioStreamManager.emissionIntervalAnalysis, 0.2, accuracy: 0.001)
55
+ }
56
+
57
+ func testEventEmissionTiming() {
58
+ let expectation = self.expectation(description: "Should emit events at correct intervals")
59
+ var eventTimestamps: [TimeInterval] = []
60
+ let testDuration: TimeInterval = 0.5 // 500ms
61
+
62
+ // Configure for 10ms intervals
63
+ let config = RecordingConfig()
64
+ config.interval = 10
65
+ config.intervalAnalysis = 10
66
+ config.enableProcessing = true
67
+ config.features = ["fft": true]
68
+
69
+ // Mock event handler to capture timestamps
70
+ audioStreamManager.onAudioData = { _ in
71
+ eventTimestamps.append(Date().timeIntervalSince1970)
72
+ }
73
+
74
+ audioStreamManager.startRecording(with: config) { error in
75
+ XCTAssertNil(error, "Should start recording successfully")
76
+
77
+ // Record for test duration
78
+ DispatchQueue.main.asyncAfter(deadline: .now() + testDuration) {
79
+ self.audioStreamManager.stopRecording()
80
+
81
+ // Analyze intervals
82
+ if eventTimestamps.count > 1 {
83
+ var intervals: [TimeInterval] = []
84
+ for i in 1..<eventTimestamps.count {
85
+ intervals.append((eventTimestamps[i] - eventTimestamps[i-1]) * 1000) // Convert to ms
86
+ }
87
+
88
+ let avgInterval = intervals.reduce(0, +) / Double(intervals.count)
89
+ let minInterval = intervals.min() ?? 0
90
+ let maxInterval = intervals.max() ?? 0
91
+
92
+ print("Event emission intervals - Avg: \(avgInterval)ms, Min: \(minInterval)ms, Max: \(maxInterval)ms")
93
+
94
+ // With the fix, average should be close to 10ms
95
+ XCTAssertLessThan(abs(avgInterval - 10), 5, "Average interval should be close to 10ms")
96
+ XCTAssertGreaterThan(minInterval, 5, "Minimum interval should be at least 5ms")
97
+ }
98
+
99
+ expectation.fulfill()
100
+ }
101
+ }
102
+
103
+ waitForExpectations(timeout: testDuration + 1.0)
104
+ }
105
+ }
@@ -0,0 +1,41 @@
1
+ # iOS Audio Format Tests
2
+
3
+ This directory contains test scripts for validating audio format support on iOS/macOS.
4
+
5
+ ## Opus Support Test
6
+
7
+ The `opus_support_test_macos.swift` script verifies that while `kAudioFormatOpus` is defined in the iOS SDK, AVAudioRecorder cannot actually encode Opus audio.
8
+
9
+ ### Running the Test
10
+
11
+ ```bash
12
+ # On macOS (for quick validation)
13
+ swift opus_support_test_macos.swift
14
+
15
+ # On iOS device/simulator (requires Xcode)
16
+ # Copy the test to an iOS project and run it
17
+ ```
18
+
19
+ ### Test Results
20
+
21
+ - ✅ `kAudioFormatOpus` constant exists (value: 1869641075)
22
+ - ✅ AVAudioRecorder accepts Opus settings without errors
23
+ - ❌ Recording produces 0-byte files (no actual encoding)
24
+ - ✅ AAC format works correctly as fallback
25
+
26
+ ### Why This Matters
27
+
28
+ This test proves that expo-audio-studio's automatic fallback from Opus to AAC on iOS is necessary and correct. Despite the SDK defining the Opus format constant, the actual encoding functionality is not implemented in AVAudioRecorder.
29
+
30
+ ## Format Verification
31
+
32
+ To verify actual file formats:
33
+
34
+ ```bash
35
+ # Check file type
36
+ file recording.m4a # Should show: ISO Media, MP4 Base Media
37
+ file recording.aac # Should show: ADTS, AAC
38
+
39
+ # Get detailed info (requires mediainfo)
40
+ mediainfo recording.m4a
41
+ ```
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env swift
2
+
3
+ import AVFoundation
4
+ import Foundation
5
+
6
+ // Test script to verify if AVAudioRecorder actually supports Opus encoding
7
+ // This version is macOS-compatible for testing purposes
8
+
9
+ func testOpusSupport() {
10
+ print("Testing AVAudioRecorder Opus Support (macOS test)...")
11
+ print("--------------------------------------------------")
12
+
13
+ // Test 1: Check if kAudioFormatOpus is defined
14
+ let opusFormat = kAudioFormatOpus
15
+ print("✓ kAudioFormatOpus is defined: \(opusFormat) (0x\(String(opusFormat, radix: 16)))")
16
+
17
+ // Convert to FourCC string
18
+ let fourCC = String(format: "%c%c%c%c",
19
+ (opusFormat >> 24) & 0xff,
20
+ (opusFormat >> 16) & 0xff,
21
+ (opusFormat >> 8) & 0xff,
22
+ opusFormat & 0xff)
23
+ print(" FourCC: '\(fourCC)'")
24
+ print()
25
+
26
+ // Test 2: Try to create AVAudioRecorder with Opus settings
27
+ print("Testing AVAudioRecorder with Opus settings...")
28
+
29
+ let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
30
+ let opusURL = documentsPath.appendingPathComponent("test_opus.opus")
31
+ let aacURL = documentsPath.appendingPathComponent("test_aac.m4a")
32
+
33
+ // Opus settings
34
+ let opusSettings: [String: Any] = [
35
+ AVFormatIDKey: kAudioFormatOpus,
36
+ AVSampleRateKey: 48000,
37
+ AVNumberOfChannelsKey: 1,
38
+ AVEncoderBitRateKey: 64000
39
+ ]
40
+
41
+ // AAC settings for comparison
42
+ let aacSettings: [String: Any] = [
43
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
44
+ AVSampleRateKey: 48000,
45
+ AVNumberOfChannelsKey: 1,
46
+ AVEncoderBitRateKey: 64000
47
+ ]
48
+
49
+ // Test Opus recorder
50
+ do {
51
+ let opusRecorder = try AVAudioRecorder(url: opusURL, settings: opusSettings)
52
+ print("✓ Opus recorder created successfully")
53
+ print(" URL: \(opusURL.lastPathComponent)")
54
+ print(" Settings provided: \(opusSettings)")
55
+ print(" Settings after init: \(opusRecorder.settings)")
56
+ print(" Format: \(opusRecorder.format)")
57
+
58
+ // Check if recorder can prepare
59
+ if opusRecorder.prepareToRecord() {
60
+ print("✓ Opus recorder prepared successfully")
61
+
62
+ // Try to record for a brief moment
63
+ if opusRecorder.record() {
64
+ print("✓ Opus recorder started recording")
65
+ Thread.sleep(forTimeInterval: 0.5)
66
+ opusRecorder.stop()
67
+ print("✓ Opus recorder stopped")
68
+
69
+ // Check if file was created
70
+ if FileManager.default.fileExists(atPath: opusURL.path) {
71
+ let attributes = try FileManager.default.attributesOfItem(atPath: opusURL.path)
72
+ let fileSize = attributes[.size] as? Int64 ?? 0
73
+ print("✓ Opus file created: \(fileSize) bytes")
74
+
75
+ // Check file format by reading header
76
+ if fileSize > 0 {
77
+ let fileHandle = try FileHandle(forReadingFrom: opusURL)
78
+ let headerData = fileHandle.readData(ofLength: 32)
79
+ fileHandle.closeFile()
80
+
81
+ print(" File header (hex): \(headerData.map { String(format: "%02X", $0) }.prefix(16).joined(separator: " "))")
82
+
83
+ // Check for common audio file signatures
84
+ if headerData.count >= 4 {
85
+ let signature = headerData.prefix(4)
86
+ if signature.starts(with: "OggS".data(using: .ascii)!) {
87
+ print(" ✓ File has OGG container signature")
88
+ } else if signature.starts(with: [0x00, 0x00, 0x00]) {
89
+ print(" File might be MP4/M4A container")
90
+ } else {
91
+ print(" Unknown file signature")
92
+ }
93
+ }
94
+ }
95
+
96
+ // Clean up
97
+ try FileManager.default.removeItem(at: opusURL)
98
+ } else {
99
+ print("✗ No Opus file was created")
100
+ }
101
+ } else {
102
+ print("✗ Opus recorder failed to start recording")
103
+ }
104
+ } else {
105
+ print("✗ Opus recorder failed to prepare")
106
+ }
107
+ } catch {
108
+ print("✗ Failed to create Opus recorder: \(error)")
109
+ print(" Error details: \(error.localizedDescription)")
110
+ }
111
+
112
+ print()
113
+
114
+ // Test AAC recorder for comparison
115
+ print("Testing AVAudioRecorder with AAC settings (for comparison)...")
116
+ do {
117
+ let aacRecorder = try AVAudioRecorder(url: aacURL, settings: aacSettings)
118
+ print("✓ AAC recorder created successfully")
119
+ print(" Settings after init: \(aacRecorder.settings)")
120
+ print(" Format: \(aacRecorder.format)")
121
+
122
+ if aacRecorder.prepareToRecord() && aacRecorder.record() {
123
+ print("✓ AAC recorder working normally")
124
+ Thread.sleep(forTimeInterval: 0.5)
125
+ aacRecorder.stop()
126
+
127
+ if FileManager.default.fileExists(atPath: aacURL.path) {
128
+ let attributes = try FileManager.default.attributesOfItem(atPath: aacURL.path)
129
+ let fileSize = attributes[.size] as? Int64 ?? 0
130
+ print("✓ AAC file created: \(fileSize) bytes")
131
+
132
+ // Check file header
133
+ let fileHandle = try FileHandle(forReadingFrom: aacURL)
134
+ let headerData = fileHandle.readData(ofLength: 16)
135
+ fileHandle.closeFile()
136
+
137
+ print(" File header (hex): \(headerData.map { String(format: "%02X", $0) }.joined(separator: " "))")
138
+
139
+ try FileManager.default.removeItem(at: aacURL)
140
+ }
141
+ }
142
+ } catch {
143
+ print("✗ Failed to create AAC recorder: \(error)")
144
+ }
145
+
146
+ print()
147
+ print("Test complete!")
148
+ print()
149
+ print("Note: This test runs on macOS which may have different codec support than iOS.")
150
+ print("The results should be validated on an actual iOS device or simulator.")
151
+ }
152
+
153
+ // Run the test
154
+ testOpusSupport()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-studio",
3
- "version": "2.10.6",
3
+ "version": "2.12.0",
4
4
  "description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -84,7 +84,7 @@
84
84
  "test:android:unit": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:test",
85
85
  "test:android:instrumented": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:connectedAndroidTest",
86
86
  "test:android:unit:watch": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:test --continuous",
87
- "test:ios": "cd ios && xcodebuild test -workspace ExpoAudioStudio.xcworkspace -scheme ExpoAudioStudio -destination 'platform=iOS Simulator,name=iPhone 14'",
87
+ "test:ios": "cd ../../apps/playground/ios && xcodebuild -workspace AudioDevPlayground.xcworkspace -scheme AudioDevPlayground -destination 'platform=iOS Simulator,name=iPhone 15' build",
88
88
  "test:coverage": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:jacocoTestReport",
89
89
  "typecheck": "tsc --noEmit",
90
90
  "docgen": "typedoc src/index.ts --plugin typedoc-plugin-markdown --readme none --out ../../documentation_site/docs/api-reference/API",
@@ -207,6 +207,21 @@ export interface IOSConfig {
207
207
  audioSession?: AudioSessionConfig
208
208
  }
209
209
 
210
+ /** Android platform specific configuration options */
211
+ export interface AndroidConfig {
212
+ /**
213
+ * Audio focus strategy for handling interruptions and background behavior
214
+ *
215
+ * - `'background'`: Continue recording when app loses focus (voice recorders, transcription apps)
216
+ * - `'interactive'`: Pause when losing focus, resume when gaining (music apps, games)
217
+ * - `'communication'`: Maintain priority for real-time communication (video calls, voice chat)
218
+ * - `'none'`: No automatic audio focus management (custom handling)
219
+ *
220
+ * @default 'background' when keepAwake=true, 'interactive' otherwise
221
+ */
222
+ audioFocusStrategy?: 'background' | 'interactive' | 'communication' | 'none'
223
+ }
224
+
210
225
  /** Web platform specific configuration options */
211
226
  export interface WebConfig {
212
227
  // Reserved for future web-specific options
@@ -311,6 +326,13 @@ export interface OutputConfig {
311
326
  format?: 'aac' | 'opus'
312
327
  /** Bitrate for compression in bits per second (default: 128000) */
313
328
  bitrate?: number
329
+ /**
330
+ * Prefer raw stream over container format (Android only)
331
+ * - true: Use raw AAC stream (.aac files) like in v2.10.6
332
+ * - false/undefined: Use M4A container (.m4a files) for better seeking support
333
+ * Note: iOS always produces M4A containers and ignores this flag
334
+ */
335
+ preferRawStream?: boolean
314
336
  }
315
337
 
316
338
  // Future enhancement: Post-processing pipeline
@@ -332,10 +354,10 @@ export interface RecordingConfig {
332
354
  /** Encoding type for the recording (pcm_32bit, pcm_16bit, pcm_8bit) */
333
355
  encoding?: EncodingType
334
356
 
335
- /** Interval in milliseconds at which to emit recording data */
357
+ /** Interval in milliseconds at which to emit recording data (minimum: 10ms) */
336
358
  interval?: number
337
359
 
338
- /** Interval in milliseconds at which to emit analysis data */
360
+ /** Interval in milliseconds at which to emit analysis data (minimum: 10ms) */
339
361
  intervalAnalysis?: number
340
362
 
341
363
  /** Keep the device awake while recording (default is false) */
@@ -356,6 +378,9 @@ export interface RecordingConfig {
356
378
  /** iOS-specific configuration */
357
379
  ios?: IOSConfig
358
380
 
381
+ /** Android-specific configuration */
382
+ android?: AndroidConfig
383
+
359
384
  /** Web-specific configuration options */
360
385
  web?: WebConfig
361
386