@siteed/audio-studio 3.1.0 → 3.1.1
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 +10 -1
- package/README.md +97 -50
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +53 -10
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
- package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
- package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/cjs/errors/AudioExtractionError.js +127 -0
- package/build/cjs/errors/AudioExtractionError.js.map +1 -0
- package/build/cjs/index.js +6 -1
- package/build/cjs/index.js.map +1 -1
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreview.js +92 -15
- package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
- package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/esm/errors/AudioExtractionError.js +122 -0
- package/build/esm/errors/AudioExtractionError.js.map +1 -0
- package/build/esm/index.js +2 -0
- package/build/esm/index.js.map +1 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
- package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
- package/build/types/errors/AudioExtractionError.d.ts +24 -0
- package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -1
- package/ios/AudioProcessor.swift +99 -0
- package/ios/AudioStudioModule.swift +63 -0
- package/package.json +7 -7
- package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
- package/src/AudioAnalysis/extractPreview.ts +118 -17
- package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
- package/src/errors/AudioExtractionError.ts +167 -0
- package/src/index.ts +10 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
package net.siteed.audiostudio
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
6
|
+
import androidx.test.platform.app.InstrumentationRegistry
|
|
7
|
+
import androidx.test.rule.GrantPermissionRule
|
|
8
|
+
import expo.modules.kotlin.Promise
|
|
9
|
+
import org.junit.After
|
|
10
|
+
import org.junit.Assert.assertEquals
|
|
11
|
+
import org.junit.Assert.assertTrue
|
|
12
|
+
import org.junit.Assume.assumeTrue
|
|
13
|
+
import org.junit.Before
|
|
14
|
+
import org.junit.Rule
|
|
15
|
+
import org.junit.Test
|
|
16
|
+
import org.junit.runner.RunWith
|
|
17
|
+
import java.io.File
|
|
18
|
+
import java.net.URI
|
|
19
|
+
import java.util.concurrent.CountDownLatch
|
|
20
|
+
import java.util.concurrent.TimeUnit
|
|
21
|
+
import kotlin.math.abs
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Device-only regression skeleton for compressed Opus range decode. It records a
|
|
25
|
+
* short Opus-only file, then verifies the range loader reports metadata and byte
|
|
26
|
+
* length from final target-converted PCM rather than source decoder output.
|
|
27
|
+
*/
|
|
28
|
+
@RunWith(AndroidJUnit4::class)
|
|
29
|
+
class OpusRangeDecodeRegressionInstrumentedTest {
|
|
30
|
+
@get:Rule
|
|
31
|
+
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.RECORD_AUDIO)
|
|
32
|
+
|
|
33
|
+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
34
|
+
private val filesDir = context.filesDir
|
|
35
|
+
private lateinit var audioRecorderManager: AudioRecorderManager
|
|
36
|
+
|
|
37
|
+
@Before
|
|
38
|
+
fun setUp() {
|
|
39
|
+
assumeTrue("Opus MediaCodec recording requires Android Q+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
40
|
+
audioRecorderManager = AudioRecorderManager.initialize(
|
|
41
|
+
context = context,
|
|
42
|
+
filesDir = filesDir,
|
|
43
|
+
permissionUtils = PermissionUtils(context),
|
|
44
|
+
audioDataEncoder = AudioDataEncoder(),
|
|
45
|
+
eventSender = object : EventSender {
|
|
46
|
+
override fun sendExpoEvent(eventName: String, params: android.os.Bundle) = Unit
|
|
47
|
+
},
|
|
48
|
+
enablePhoneStateHandling = false,
|
|
49
|
+
enableBackgroundAudio = false
|
|
50
|
+
)
|
|
51
|
+
cleanupGeneratedAudio()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@After
|
|
55
|
+
fun tearDown() {
|
|
56
|
+
if (::audioRecorderManager.isInitialized && audioRecorderManager.isRecording) {
|
|
57
|
+
runCatching { stopRecordingSync() }
|
|
58
|
+
}
|
|
59
|
+
AudioRecorderManager.destroy()
|
|
60
|
+
cleanupGeneratedAudio()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun loadAudioRange_returnsTargetPcmMetadataForGeneratedOpusRecording() {
|
|
65
|
+
startRecordingSync(
|
|
66
|
+
mapOf(
|
|
67
|
+
"sampleRate" to 48_000,
|
|
68
|
+
"channels" to 1,
|
|
69
|
+
"encoding" to "pcm_16bit",
|
|
70
|
+
"interval" to 100,
|
|
71
|
+
"showNotification" to false,
|
|
72
|
+
"output" to mapOf(
|
|
73
|
+
"primary" to mapOf("enabled" to false),
|
|
74
|
+
"compressed" to mapOf(
|
|
75
|
+
"enabled" to true,
|
|
76
|
+
"format" to "opus"
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
Thread.sleep(RECORDING_DURATION_MS + 250L)
|
|
82
|
+
val opusFile = resolveCompressedFile(stopRecordingSync())
|
|
83
|
+
|
|
84
|
+
val audioData = AudioProcessor(filesDir).loadAudioRange(
|
|
85
|
+
fileUri = opusFile.absolutePath,
|
|
86
|
+
startTimeMs = 0,
|
|
87
|
+
endTimeMs = REQUESTED_RANGE_MS,
|
|
88
|
+
config = DecodingConfig(
|
|
89
|
+
targetSampleRate = TARGET_SAMPLE_RATE,
|
|
90
|
+
targetChannels = TARGET_CHANNELS,
|
|
91
|
+
targetBitDepth = TARGET_BIT_DEPTH,
|
|
92
|
+
normalizeAudio = false
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
val decoded = requireNotNull(audioData) { "Opus range should decode without BufferOverflowException" }
|
|
97
|
+
assertEquals("sampleRate should describe final converted PCM", TARGET_SAMPLE_RATE, decoded.sampleRate)
|
|
98
|
+
assertEquals("channels should describe final converted PCM", TARGET_CHANNELS, decoded.channels)
|
|
99
|
+
assertEquals("bitDepth should describe final converted PCM", TARGET_BIT_DEPTH, decoded.bitDepth)
|
|
100
|
+
|
|
101
|
+
val expectedBytes = TARGET_SAMPLE_RATE * TARGET_CHANNELS * (TARGET_BIT_DEPTH / 8) * REQUESTED_RANGE_MS / 1_000
|
|
102
|
+
val toleranceBytes = TARGET_SAMPLE_RATE * TARGET_CHANNELS * (TARGET_BIT_DEPTH / 8) / 2
|
|
103
|
+
assertTrue(
|
|
104
|
+
"final PCM byte length should be near requested target duration: expected=$expectedBytes actual=${decoded.data.size}",
|
|
105
|
+
abs(decoded.data.size - expectedBytes) <= toleranceBytes
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun startRecordingSync(recordingOptions: Map<String, Any?>) {
|
|
110
|
+
val latch = CountDownLatch(1)
|
|
111
|
+
var rejected: String? = null
|
|
112
|
+
audioRecorderManager.startRecording(recordingOptions, object : Promise {
|
|
113
|
+
override fun resolve(value: Any?) {
|
|
114
|
+
latch.countDown()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
118
|
+
rejected = "$code - $message"
|
|
119
|
+
latch.countDown()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
assertTrue("Recording should start within 2 seconds", latch.await(2, TimeUnit.SECONDS))
|
|
123
|
+
rejected?.let { throw AssertionError("Recording start failed: $it") }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private fun stopRecordingSync(): Map<String, Any?> {
|
|
127
|
+
val latch = CountDownLatch(1)
|
|
128
|
+
var result: Map<String, Any?>? = null
|
|
129
|
+
var rejected: String? = null
|
|
130
|
+
audioRecorderManager.stopRecording(object : Promise {
|
|
131
|
+
override fun resolve(value: Any?) {
|
|
132
|
+
result = when (value) {
|
|
133
|
+
is android.os.Bundle -> bundleToMap(value)
|
|
134
|
+
is Map<*, *> -> value.entries.associate { it.key.toString() to it.value }
|
|
135
|
+
else -> throw AssertionError("Unexpected stop result type: ${value?.javaClass?.name}")
|
|
136
|
+
}
|
|
137
|
+
latch.countDown()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
141
|
+
rejected = "$code - $message"
|
|
142
|
+
latch.countDown()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
assertTrue("Recording should stop within 2 seconds", latch.await(2, TimeUnit.SECONDS))
|
|
146
|
+
rejected?.let { throw AssertionError("Recording stop failed: $it") }
|
|
147
|
+
return result ?: throw AssertionError("Stop result should not be null")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun resolveCompressedFile(result: Map<String, Any?>): File {
|
|
151
|
+
val compression = result["compression"]
|
|
152
|
+
val compressionMap = when (compression) {
|
|
153
|
+
is android.os.Bundle -> bundleToMap(compression)
|
|
154
|
+
is Map<*, *> -> compression.entries.associate { it.key.toString() to it.value }
|
|
155
|
+
else -> emptyMap()
|
|
156
|
+
}
|
|
157
|
+
val uri = compressionMap["compressedFileUri"] as? String
|
|
158
|
+
?: throw AssertionError("Compressed Opus URI should be present in stop result: $result")
|
|
159
|
+
val file = when {
|
|
160
|
+
uri.startsWith("file://") || uri.startsWith("file:") -> File(URI(uri))
|
|
161
|
+
else -> File(uri)
|
|
162
|
+
}
|
|
163
|
+
assertTrue("Generated Opus file should exist: ${file.absolutePath}", file.exists())
|
|
164
|
+
assertTrue("Generated file should be Opus: ${file.name}", file.name.endsWith(".opus"))
|
|
165
|
+
return file
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private fun bundleToMap(bundle: android.os.Bundle): Map<String, Any?> =
|
|
169
|
+
bundle.keySet().associateWith { key -> bundle.get(key) }
|
|
170
|
+
|
|
171
|
+
private fun cleanupGeneratedAudio() {
|
|
172
|
+
filesDir.listFiles()?.forEach { file ->
|
|
173
|
+
if (file.name.startsWith("recording_") && file.extension in setOf("opus", "wav", "m4a", "aac")) {
|
|
174
|
+
file.delete()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
companion object {
|
|
180
|
+
private const val RECORDING_DURATION_MS = 2_000L
|
|
181
|
+
private const val REQUESTED_RANGE_MS = 2_000L
|
|
182
|
+
private const val TARGET_SAMPLE_RATE = 16_000
|
|
183
|
+
private const val TARGET_CHANNELS = 1
|
|
184
|
+
private const val TARGET_BIT_DEPTH = 16
|
|
185
|
+
}
|
|
186
|
+
}
|