@siteed/expo-audio-studio 2.10.5 → 2.11.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 +14 -1
- 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/main/java/net/siteed/audiostream/AudioProcessor.kt +44 -32
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +25 -4
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +7 -4
- 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 +9 -2
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/ios/AudioStreamManager.swift +22 -4
- 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 +9 -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.11.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",
|
|
@@ -311,6 +311,13 @@ export interface OutputConfig {
|
|
|
311
311
|
format?: 'aac' | 'opus'
|
|
312
312
|
/** Bitrate for compression in bits per second (default: 128000) */
|
|
313
313
|
bitrate?: number
|
|
314
|
+
/**
|
|
315
|
+
* Prefer raw stream over container format (Android only)
|
|
316
|
+
* - true: Use raw AAC stream (.aac files) like in v2.10.6
|
|
317
|
+
* - false/undefined: Use M4A container (.m4a files) for better seeking support
|
|
318
|
+
* Note: iOS always produces M4A containers and ignores this flag
|
|
319
|
+
*/
|
|
320
|
+
preferRawStream?: boolean
|
|
314
321
|
}
|
|
315
322
|
|
|
316
323
|
// Future enhancement: Post-processing pipeline
|
|
@@ -332,10 +339,10 @@ export interface RecordingConfig {
|
|
|
332
339
|
/** Encoding type for the recording (pcm_32bit, pcm_16bit, pcm_8bit) */
|
|
333
340
|
encoding?: EncodingType
|
|
334
341
|
|
|
335
|
-
/** Interval in milliseconds at which to emit recording data */
|
|
342
|
+
/** Interval in milliseconds at which to emit recording data (minimum: 10ms) */
|
|
336
343
|
interval?: number
|
|
337
344
|
|
|
338
|
-
/** Interval in milliseconds at which to emit analysis data */
|
|
345
|
+
/** Interval in milliseconds at which to emit analysis data (minimum: 10ms) */
|
|
339
346
|
intervalAnalysis?: number
|
|
340
347
|
|
|
341
348
|
/** Keep the device awake while recording (default is false) */
|