@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.
- package/CHANGELOG.md +15 -1
- package/android/src/androidTest/java/net/siteed/audiostream/integration/AudioFocusStrategyIntegrationTest.kt +332 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/DeviceDisconnectionFallbackTest.kt +218 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/EventEmissionIntervalTest.kt +120 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/M4aFormatTest.kt +345 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/PcmStreamingDurationTest.kt +252 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +5 -0
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +44 -32
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +198 -22
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +13 -4
- package/android/src/test/java/net/siteed/audiostream/AudioFocusStrategyTest.kt +249 -0
- package/android/src/test/java/net/siteed/audiostream/AudioFormatTest.kt +151 -0
- package/android/src/test/java/net/siteed/audiostream/DeviceDisconnectionFallbackUnitTest.kt +140 -0
- package/build/cjs/ExpoAudioStream.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.types.js.map +1 -1
- package/build/types/ExpoAudioStream.types.d.ts +25 -2
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/ios/AudioStreamManager.swift +55 -43
- package/ios/ExpoAudioStudioTests/EventEmissionIntervalTests.swift +105 -0
- package/ios/tests/README.md +41 -0
- package/ios/tests/opus_support_test_macos.swift +154 -0
- package/package.json +2 -2
- 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.
|
|
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
|
|
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
|
|