@siteed/audio-studio 3.1.1 → 3.2.0-beta.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 +20 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +134 -3
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/errors/AudioStreamError.js +152 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +61 -0
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
- package/build/cjs/index.js +7 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/streamAudioData.js +467 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- package/build/esm/errors/AudioStreamError.js +147 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +59 -0
- package/build/esm/errors/AudioStreamError.test.js.map +1 -0
- package/build/esm/index.js +3 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/streamAudioData.js +460 -0
- package/build/esm/streamAudioData.js.map +1 -0
- package/build/types/errors/AudioStreamError.d.ts +25 -0
- package/build/types/errors/AudioStreamError.d.ts.map +1 -0
- package/build/types/errors/AudioStreamError.test.d.ts +2 -0
- package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
- package/build/types/index.d.ts +5 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +114 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioStreamDecoder.swift +523 -0
- package/ios/AudioStudioModule.swift +147 -3
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
- package/package.json +1 -1
- package/src/errors/AudioStreamError.test.ts +65 -0
- package/src/errors/AudioStreamError.ts +185 -0
- package/src/index.ts +24 -0
- package/src/streamAudioData.ts +654 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `streamAudioData(options, callbacks)` — progressive Float32 file decode for
|
|
13
|
+
long compressed recordings without full-PCM materialization. Supports
|
|
14
|
+
`AbortSignal` cancellation, async `onChunk` back-pressure, target sample
|
|
15
|
+
rate, channel downmix, and stable error codes via the new
|
|
16
|
+
`AudioStreamError` class.
|
|
17
|
+
- `getAudioDecodeCapabilities()` for platform feature discovery.
|
|
18
|
+
- iOS: `AVAssetReader`-based decoder with per-`requestId` cancellation.
|
|
19
|
+
- Android: `MediaExtractor` + `MediaCodec` decoder with linear resampling that
|
|
20
|
+
preserves continuity across codec buffer boundaries.
|
|
21
|
+
- Docs: `docs/STREAM_AUDIO_DATA.md`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- iOS `extractRawAudioData` no longer traps on non-finite or out-of-range
|
|
26
|
+
decoded samples. Float-to-`Int16`/`Int32` conversion now replaces NaN/Inf
|
|
27
|
+
with `0` and clamps the scaled value into the integer range before
|
|
28
|
+
conversion.
|
|
29
|
+
|
|
10
30
|
## [3.1.1] - 2026-05-08
|
|
11
31
|
### Fixed
|
|
12
32
|
- Trust Android final decoded PCM metadata for range extraction and trimming, including JS `Number` options bridged as Kotlin `Double`.
|
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
// packages/audio-studio/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt
|
|
2
|
+
//
|
|
3
|
+
// Progressive file decoder for `streamAudioData`. Uses MediaExtractor +
|
|
4
|
+
// MediaCodec to decode a stored audio file into bounded Float32 PCM chunks
|
|
5
|
+
// without loading the full PCM range into memory. Output is sanitized
|
|
6
|
+
// (NaN/Inf -> 0, clamp to [-1, 1] when normalizeAudio=true) and supports
|
|
7
|
+
// per-request cancellation + back-pressure ack.
|
|
8
|
+
package net.siteed.audiostudio
|
|
9
|
+
|
|
10
|
+
import android.content.Context
|
|
11
|
+
import android.media.MediaCodec
|
|
12
|
+
import android.media.MediaExtractor
|
|
13
|
+
import android.media.MediaFormat
|
|
14
|
+
import android.net.Uri
|
|
15
|
+
import android.os.Bundle
|
|
16
|
+
import androidx.core.os.bundleOf
|
|
17
|
+
import java.io.File
|
|
18
|
+
import java.nio.ByteBuffer
|
|
19
|
+
import java.nio.ByteOrder
|
|
20
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
21
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
22
|
+
import kotlin.concurrent.thread
|
|
23
|
+
|
|
24
|
+
interface AudioStreamDecoderDelegate {
|
|
25
|
+
fun streamDecoderEmit(eventName: String, payload: Bundle)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class AudioStreamDecoder(
|
|
29
|
+
private val context: Context,
|
|
30
|
+
private val options: Options,
|
|
31
|
+
private val delegate: AudioStreamDecoderDelegate,
|
|
32
|
+
) {
|
|
33
|
+
data class Options(
|
|
34
|
+
val requestId: String,
|
|
35
|
+
val fileUri: String,
|
|
36
|
+
val startTimeMs: Long?,
|
|
37
|
+
val endTimeMs: Long?,
|
|
38
|
+
val targetSampleRate: Int?,
|
|
39
|
+
val channels: Int?,
|
|
40
|
+
val normalizeAudio: Boolean,
|
|
41
|
+
val chunkDurationMs: Int,
|
|
42
|
+
val maxChunkBytes: Int?,
|
|
43
|
+
val maxBufferedChunks: Int,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
companion object {
|
|
47
|
+
private const val TAG = "AudioStreamDecoder"
|
|
48
|
+
private const val TIMEOUT_US = 10_000L
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private val cancelled = AtomicBoolean(false)
|
|
52
|
+
private val lastAckedIndex = AtomicInteger(-1)
|
|
53
|
+
private val ackLock = Object()
|
|
54
|
+
private var workerThread: Thread? = null
|
|
55
|
+
|
|
56
|
+
fun start() {
|
|
57
|
+
workerThread = thread(
|
|
58
|
+
isDaemon = true,
|
|
59
|
+
name = "AudioStreamDecoder-${options.requestId}"
|
|
60
|
+
) {
|
|
61
|
+
run()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun cancel() {
|
|
66
|
+
cancelled.set(true)
|
|
67
|
+
synchronized(ackLock) { ackLock.notifyAll() }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun acknowledgeChunk(index: Int) {
|
|
71
|
+
synchronized(ackLock) {
|
|
72
|
+
if (index > lastAckedIndex.get()) {
|
|
73
|
+
lastAckedIndex.set(index)
|
|
74
|
+
}
|
|
75
|
+
ackLock.notifyAll()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun run() {
|
|
80
|
+
val path = resolveFilePath(options.fileUri)
|
|
81
|
+
if (path == null) {
|
|
82
|
+
emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "Cannot resolve file: ${options.fileUri}")
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (!File(path).exists()) {
|
|
86
|
+
emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "File not found: $path")
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
val extractor = MediaExtractor()
|
|
91
|
+
var codec: MediaCodec? = null
|
|
92
|
+
var emittedChunks = 0
|
|
93
|
+
var emittedSamples = 0L
|
|
94
|
+
var totalDurationUs: Long = -1
|
|
95
|
+
var outputSampleRate = options.targetSampleRate ?: 0
|
|
96
|
+
var outputChannels = options.channels ?: 0
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
try {
|
|
100
|
+
extractor.setDataSource(path)
|
|
101
|
+
} catch (e: Exception) {
|
|
102
|
+
emitError(
|
|
103
|
+
"ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
104
|
+
"setDataSource failed: ${e.message}"
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
|
|
110
|
+
val format = extractor.getTrackFormat(idx)
|
|
111
|
+
format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
|
112
|
+
}
|
|
113
|
+
if (trackIndex == null) {
|
|
114
|
+
emitError(
|
|
115
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
116
|
+
"No audio track found"
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
extractor.selectTrack(trackIndex)
|
|
121
|
+
val format = extractor.getTrackFormat(trackIndex)
|
|
122
|
+
totalDurationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
|
123
|
+
format.getLong(MediaFormat.KEY_DURATION)
|
|
124
|
+
} else {
|
|
125
|
+
-1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
val sourceSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
129
|
+
val sourceChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
130
|
+
if (outputSampleRate <= 0) outputSampleRate = sourceSampleRate
|
|
131
|
+
if (outputChannels <= 0) outputChannels = minOf(2, maxOf(1, sourceChannels))
|
|
132
|
+
|
|
133
|
+
options.startTimeMs?.let {
|
|
134
|
+
extractor.seekTo(it * 1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
val mime = format.getString(MediaFormat.KEY_MIME)
|
|
138
|
+
?: run {
|
|
139
|
+
emitError(
|
|
140
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
141
|
+
"Track has no MIME"
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
codec = try {
|
|
146
|
+
MediaCodec.createDecoderByType(mime)
|
|
147
|
+
} catch (e: Exception) {
|
|
148
|
+
emitError(
|
|
149
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
150
|
+
"No decoder for $mime: ${e.message}"
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
codec.configure(format, null, null, 0)
|
|
155
|
+
codec.start()
|
|
156
|
+
|
|
157
|
+
val endTimeUs = options.endTimeMs?.let { it * 1000L } ?: Long.MAX_VALUE
|
|
158
|
+
val rangeStartMs = options.startTimeMs ?: 0L
|
|
159
|
+
val samplesPerChunk = run {
|
|
160
|
+
val byTime = (options.chunkDurationMs.toLong() *
|
|
161
|
+
outputSampleRate.toLong() / 1000L).toInt() * outputChannels
|
|
162
|
+
val byBytes = options.maxChunkBytes?.let { it / 4 } ?: Int.MAX_VALUE
|
|
163
|
+
maxOf(1, minOf(byTime, byBytes))
|
|
164
|
+
}
|
|
165
|
+
val pending = FloatArray(samplesPerChunk * 2)
|
|
166
|
+
var pendingLen = 0
|
|
167
|
+
val info = MediaCodec.BufferInfo()
|
|
168
|
+
var sawInputEOS = false
|
|
169
|
+
var sawOutputEOS = false
|
|
170
|
+
|
|
171
|
+
// Resampling state across output buffers. We keep the last source
|
|
172
|
+
// sample to interpolate across buffer boundaries.
|
|
173
|
+
val resampler = LinearResampler(outputChannels)
|
|
174
|
+
|
|
175
|
+
while (!sawOutputEOS) {
|
|
176
|
+
if (cancelled.get()) break
|
|
177
|
+
|
|
178
|
+
if (!sawInputEOS) {
|
|
179
|
+
val inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US)
|
|
180
|
+
if (inputBufferIndex >= 0) {
|
|
181
|
+
val inputBuffer = codec.getInputBuffer(inputBufferIndex)
|
|
182
|
+
if (inputBuffer != null) {
|
|
183
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
184
|
+
val sampleTime = extractor.sampleTime
|
|
185
|
+
if (sampleSize < 0 || sampleTime > endTimeUs) {
|
|
186
|
+
codec.queueInputBuffer(
|
|
187
|
+
inputBufferIndex,
|
|
188
|
+
0,
|
|
189
|
+
0,
|
|
190
|
+
0,
|
|
191
|
+
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
|
192
|
+
)
|
|
193
|
+
sawInputEOS = true
|
|
194
|
+
} else {
|
|
195
|
+
codec.queueInputBuffer(
|
|
196
|
+
inputBufferIndex,
|
|
197
|
+
0,
|
|
198
|
+
sampleSize,
|
|
199
|
+
sampleTime,
|
|
200
|
+
0
|
|
201
|
+
)
|
|
202
|
+
extractor.advance()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
val outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US)
|
|
209
|
+
if (outputIndex >= 0) {
|
|
210
|
+
val outputBuffer = codec.getOutputBuffer(outputIndex)
|
|
211
|
+
if (outputBuffer != null && info.size > 0) {
|
|
212
|
+
outputBuffer.position(info.offset)
|
|
213
|
+
outputBuffer.limit(info.offset + info.size)
|
|
214
|
+
val pcm = ByteArray(info.size)
|
|
215
|
+
outputBuffer.get(pcm)
|
|
216
|
+
|
|
217
|
+
val converted = pcmToFloat(
|
|
218
|
+
pcm,
|
|
219
|
+
sourceChannels,
|
|
220
|
+
outputChannels,
|
|
221
|
+
options.normalizeAudio
|
|
222
|
+
)
|
|
223
|
+
val resampled = resampler.process(
|
|
224
|
+
converted,
|
|
225
|
+
sourceSampleRate,
|
|
226
|
+
outputSampleRate
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
// Append into pending, grow if needed
|
|
230
|
+
val newLen = pendingLen + resampled.size
|
|
231
|
+
val target = if (newLen > pending.size) {
|
|
232
|
+
val grown = FloatArray(newLen.coerceAtLeast(pending.size * 2))
|
|
233
|
+
System.arraycopy(pending, 0, grown, 0, pendingLen)
|
|
234
|
+
grown.also {
|
|
235
|
+
System.arraycopy(it, 0, pending, 0, pendingLen)
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
pending
|
|
239
|
+
}
|
|
240
|
+
// Note: when grown, we use a transient buffer that we
|
|
241
|
+
// process and never write back into the original.
|
|
242
|
+
val workBuffer: FloatArray
|
|
243
|
+
if (target !== pending) {
|
|
244
|
+
System.arraycopy(resampled, 0, target, pendingLen, resampled.size)
|
|
245
|
+
pendingLen += resampled.size
|
|
246
|
+
workBuffer = target
|
|
247
|
+
} else {
|
|
248
|
+
System.arraycopy(resampled, 0, pending, pendingLen, resampled.size)
|
|
249
|
+
pendingLen += resampled.size
|
|
250
|
+
workBuffer = pending
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
while (pendingLen >= samplesPerChunk) {
|
|
254
|
+
if (cancelled.get()) break
|
|
255
|
+
val chunk = FloatArray(samplesPerChunk)
|
|
256
|
+
System.arraycopy(workBuffer, 0, chunk, 0, samplesPerChunk)
|
|
257
|
+
// Shift remainder forward.
|
|
258
|
+
val remainder = pendingLen - samplesPerChunk
|
|
259
|
+
if (remainder > 0) {
|
|
260
|
+
System.arraycopy(
|
|
261
|
+
workBuffer,
|
|
262
|
+
samplesPerChunk,
|
|
263
|
+
workBuffer,
|
|
264
|
+
0,
|
|
265
|
+
remainder
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
pendingLen = remainder
|
|
269
|
+
|
|
270
|
+
val chunkDurationMs =
|
|
271
|
+
(chunk.size.toDouble() /
|
|
272
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
273
|
+
1000.0
|
|
274
|
+
val startMs = rangeStartMs +
|
|
275
|
+
(emittedSamples.toDouble() /
|
|
276
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
277
|
+
1000.0
|
|
278
|
+
emitChunk(
|
|
279
|
+
index = emittedChunks,
|
|
280
|
+
startTimeMs = startMs,
|
|
281
|
+
endTimeMs = startMs + chunkDurationMs,
|
|
282
|
+
startSample = (emittedSamples / outputChannels).toInt(),
|
|
283
|
+
sampleRate = outputSampleRate,
|
|
284
|
+
channels = outputChannels,
|
|
285
|
+
samples = chunk,
|
|
286
|
+
isFinal = false
|
|
287
|
+
)
|
|
288
|
+
emittedChunks += 1
|
|
289
|
+
emittedSamples += chunk.size
|
|
290
|
+
emitProgress(
|
|
291
|
+
processedMs = startMs + chunkDurationMs,
|
|
292
|
+
durationMs = if (totalDurationUs > 0) totalDurationUs / 1000.0 else 0.0,
|
|
293
|
+
emittedChunks = emittedChunks
|
|
294
|
+
)
|
|
295
|
+
if (waitForAckOrCancel(emittedChunks - 1)) {
|
|
296
|
+
cancelled.set(true)
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
codec.releaseOutputBuffer(outputIndex, false)
|
|
302
|
+
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
303
|
+
sawOutputEOS = true
|
|
304
|
+
}
|
|
305
|
+
} else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
|
306
|
+
val newFormat = codec.outputFormat
|
|
307
|
+
if (newFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
|
|
308
|
+
// Source rate from codec output overrides extractor's
|
|
309
|
+
// format when the codec exposes a different value.
|
|
310
|
+
@Suppress("UNUSED_VARIABLE")
|
|
311
|
+
val updatedRate = newFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (cancelled.get()) {
|
|
317
|
+
emitError("ERR_AUDIO_STREAM_CANCELLED", "Stream cancelled")
|
|
318
|
+
emitComplete(
|
|
319
|
+
durationMs = if (totalDurationUs > 0) totalDurationUs / 1000.0 else 0.0,
|
|
320
|
+
sampleRate = outputSampleRate,
|
|
321
|
+
channels = outputChannels,
|
|
322
|
+
chunks = emittedChunks,
|
|
323
|
+
samples = emittedSamples.toInt(),
|
|
324
|
+
cancelled = true
|
|
325
|
+
)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Flush remainder as final chunk
|
|
330
|
+
if (pendingLen > 0) {
|
|
331
|
+
val tail = FloatArray(pendingLen)
|
|
332
|
+
System.arraycopy(pending, 0, tail, 0, pendingLen)
|
|
333
|
+
val tailDurationMs =
|
|
334
|
+
(tail.size.toDouble() /
|
|
335
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
336
|
+
1000.0
|
|
337
|
+
val startMs = rangeStartMs +
|
|
338
|
+
(emittedSamples.toDouble() /
|
|
339
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
340
|
+
1000.0
|
|
341
|
+
emitChunk(
|
|
342
|
+
index = emittedChunks,
|
|
343
|
+
startTimeMs = startMs,
|
|
344
|
+
endTimeMs = startMs + tailDurationMs,
|
|
345
|
+
startSample = (emittedSamples / outputChannels).toInt(),
|
|
346
|
+
sampleRate = outputSampleRate,
|
|
347
|
+
channels = outputChannels,
|
|
348
|
+
samples = tail,
|
|
349
|
+
isFinal = true
|
|
350
|
+
)
|
|
351
|
+
emittedChunks += 1
|
|
352
|
+
emittedSamples += tail.size
|
|
353
|
+
} else if (emittedChunks > 0) {
|
|
354
|
+
emitChunk(
|
|
355
|
+
index = emittedChunks,
|
|
356
|
+
startTimeMs = rangeStartMs +
|
|
357
|
+
(emittedSamples.toDouble() /
|
|
358
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
359
|
+
1000.0,
|
|
360
|
+
endTimeMs = rangeStartMs +
|
|
361
|
+
(emittedSamples.toDouble() /
|
|
362
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
363
|
+
1000.0,
|
|
364
|
+
startSample = (emittedSamples / outputChannels).toInt(),
|
|
365
|
+
sampleRate = outputSampleRate,
|
|
366
|
+
channels = outputChannels,
|
|
367
|
+
samples = FloatArray(0),
|
|
368
|
+
isFinal = true
|
|
369
|
+
)
|
|
370
|
+
emittedChunks += 1
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
emitComplete(
|
|
374
|
+
durationMs = if (totalDurationUs > 0) totalDurationUs / 1000.0 else 0.0,
|
|
375
|
+
sampleRate = outputSampleRate,
|
|
376
|
+
channels = outputChannels,
|
|
377
|
+
chunks = emittedChunks,
|
|
378
|
+
samples = emittedSamples.toInt(),
|
|
379
|
+
cancelled = false
|
|
380
|
+
)
|
|
381
|
+
} catch (e: Exception) {
|
|
382
|
+
LogUtils.e(TAG, "Decode failed: ${e.message}", e)
|
|
383
|
+
emitError(
|
|
384
|
+
"ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
385
|
+
e.message ?: "Unknown decode error"
|
|
386
|
+
)
|
|
387
|
+
} finally {
|
|
388
|
+
try {
|
|
389
|
+
codec?.stop()
|
|
390
|
+
} catch (_: Exception) { /* noop */ }
|
|
391
|
+
try {
|
|
392
|
+
codec?.release()
|
|
393
|
+
} catch (_: Exception) { /* noop */ }
|
|
394
|
+
try {
|
|
395
|
+
extractor.release()
|
|
396
|
+
} catch (_: Exception) { /* noop */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private fun resolveFilePath(uri: String): String? {
|
|
401
|
+
if (uri.startsWith("/")) return uri
|
|
402
|
+
return try {
|
|
403
|
+
val parsed = Uri.parse(uri)
|
|
404
|
+
when (parsed.scheme) {
|
|
405
|
+
"file" -> parsed.path
|
|
406
|
+
"content" -> {
|
|
407
|
+
// Copy to cache so MediaExtractor can read by path.
|
|
408
|
+
val temp = File.createTempFile(
|
|
409
|
+
"audiostream_${options.requestId}_",
|
|
410
|
+
null,
|
|
411
|
+
context.cacheDir
|
|
412
|
+
)
|
|
413
|
+
context.contentResolver.openInputStream(parsed)?.use { input ->
|
|
414
|
+
temp.outputStream().use { out -> input.copyTo(out) }
|
|
415
|
+
}
|
|
416
|
+
temp.absolutePath
|
|
417
|
+
}
|
|
418
|
+
null -> uri
|
|
419
|
+
else -> null
|
|
420
|
+
}
|
|
421
|
+
} catch (e: Exception) {
|
|
422
|
+
LogUtils.e(TAG, "resolveFilePath failed: ${e.message}", e)
|
|
423
|
+
null
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private fun pcmToFloat(
|
|
428
|
+
pcm: ByteArray,
|
|
429
|
+
sourceChannels: Int,
|
|
430
|
+
targetChannels: Int,
|
|
431
|
+
normalize: Boolean,
|
|
432
|
+
): FloatArray {
|
|
433
|
+
if (pcm.isEmpty() || sourceChannels <= 0) return FloatArray(0)
|
|
434
|
+
val buffer = ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN)
|
|
435
|
+
val totalShorts = pcm.size / 2
|
|
436
|
+
val srcFrames = totalShorts / sourceChannels
|
|
437
|
+
val out = FloatArray(srcFrames * targetChannels)
|
|
438
|
+
var outIdx = 0
|
|
439
|
+
val tempFrame = FloatArray(sourceChannels)
|
|
440
|
+
for (frame in 0 until srcFrames) {
|
|
441
|
+
for (c in 0 until sourceChannels) {
|
|
442
|
+
val s = buffer.short.toFloat() / 32768.0f
|
|
443
|
+
val safe = if (s.isFinite()) s else 0f
|
|
444
|
+
tempFrame[c] = if (normalize) {
|
|
445
|
+
when {
|
|
446
|
+
safe > 1f -> 1f
|
|
447
|
+
safe < -1f -> -1f
|
|
448
|
+
else -> safe
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
safe
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
when {
|
|
455
|
+
sourceChannels == targetChannels -> {
|
|
456
|
+
for (c in 0 until targetChannels) {
|
|
457
|
+
out[outIdx++] = tempFrame[c]
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
targetChannels == 1 -> {
|
|
461
|
+
// Downmix average
|
|
462
|
+
var sum = 0f
|
|
463
|
+
for (c in 0 until sourceChannels) sum += tempFrame[c]
|
|
464
|
+
out[outIdx++] = sum / sourceChannels
|
|
465
|
+
}
|
|
466
|
+
sourceChannels == 1 -> {
|
|
467
|
+
// Upmix mono -> N (duplicate)
|
|
468
|
+
for (c in 0 until targetChannels) {
|
|
469
|
+
out[outIdx++] = tempFrame[0]
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else -> {
|
|
473
|
+
// Drop or duplicate channels; for simplicity take first N or zero-pad.
|
|
474
|
+
for (c in 0 until targetChannels) {
|
|
475
|
+
out[outIdx++] = if (c < sourceChannels) tempFrame[c] else 0f
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private fun waitForAckOrCancel(emittedIndex: Int): Boolean {
|
|
484
|
+
synchronized(ackLock) {
|
|
485
|
+
while (true) {
|
|
486
|
+
if (cancelled.get()) return true
|
|
487
|
+
val inFlight = emittedIndex - lastAckedIndex.get()
|
|
488
|
+
if (inFlight < options.maxBufferedChunks) return false
|
|
489
|
+
ackLock.wait(50L)
|
|
490
|
+
}
|
|
491
|
+
@Suppress("UNREACHABLE_CODE")
|
|
492
|
+
return false
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private fun emitChunk(
|
|
497
|
+
index: Int,
|
|
498
|
+
startTimeMs: Double,
|
|
499
|
+
endTimeMs: Double,
|
|
500
|
+
startSample: Int,
|
|
501
|
+
sampleRate: Int,
|
|
502
|
+
channels: Int,
|
|
503
|
+
samples: FloatArray,
|
|
504
|
+
isFinal: Boolean,
|
|
505
|
+
) {
|
|
506
|
+
// Pass FloatArray directly; expo-modules-core converts to JS array.
|
|
507
|
+
val payload = bundleOf(
|
|
508
|
+
"requestId" to options.requestId,
|
|
509
|
+
"chunkIndex" to index,
|
|
510
|
+
"startTimeMs" to startTimeMs,
|
|
511
|
+
"endTimeMs" to endTimeMs,
|
|
512
|
+
"startSample" to startSample,
|
|
513
|
+
"sampleCount" to samples.size,
|
|
514
|
+
"sampleRate" to sampleRate,
|
|
515
|
+
"channels" to channels,
|
|
516
|
+
"samples" to samples,
|
|
517
|
+
"isFinal" to isFinal,
|
|
518
|
+
)
|
|
519
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_CHUNK_EVENT, payload)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private fun emitProgress(
|
|
523
|
+
processedMs: Double,
|
|
524
|
+
durationMs: Double,
|
|
525
|
+
emittedChunks: Int,
|
|
526
|
+
) {
|
|
527
|
+
val progress = if (durationMs > 0) {
|
|
528
|
+
(processedMs / durationMs).coerceIn(0.0, 1.0)
|
|
529
|
+
} else {
|
|
530
|
+
0.0
|
|
531
|
+
}
|
|
532
|
+
val payload = bundleOf(
|
|
533
|
+
"requestId" to options.requestId,
|
|
534
|
+
"processedMs" to processedMs,
|
|
535
|
+
"durationMs" to durationMs,
|
|
536
|
+
"progress" to progress,
|
|
537
|
+
"emittedChunks" to emittedChunks,
|
|
538
|
+
)
|
|
539
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_PROGRESS_EVENT, payload)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private fun emitComplete(
|
|
543
|
+
durationMs: Double,
|
|
544
|
+
sampleRate: Int,
|
|
545
|
+
channels: Int,
|
|
546
|
+
chunks: Int,
|
|
547
|
+
samples: Int,
|
|
548
|
+
cancelled: Boolean,
|
|
549
|
+
) {
|
|
550
|
+
val payload = bundleOf(
|
|
551
|
+
"requestId" to options.requestId,
|
|
552
|
+
"durationMs" to durationMs,
|
|
553
|
+
"sampleRate" to sampleRate,
|
|
554
|
+
"channels" to channels,
|
|
555
|
+
"chunks" to chunks,
|
|
556
|
+
"samples" to samples,
|
|
557
|
+
"cancelled" to cancelled,
|
|
558
|
+
)
|
|
559
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_COMPLETE_EVENT, payload)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private fun emitError(code: String, message: String) {
|
|
563
|
+
val payload = bundleOf(
|
|
564
|
+
"requestId" to options.requestId,
|
|
565
|
+
"code" to code,
|
|
566
|
+
"message" to message,
|
|
567
|
+
)
|
|
568
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_ERROR_EVENT, payload)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Per-stream linear resampler keyed by output channel count. Maintains the
|
|
574
|
+
* last source frame across buffer boundaries so resampling is continuous.
|
|
575
|
+
*/
|
|
576
|
+
private class LinearResampler(private val channels: Int) {
|
|
577
|
+
private var lastSrcFrame: FloatArray? = null
|
|
578
|
+
private var fractional: Double = 0.0
|
|
579
|
+
|
|
580
|
+
fun process(input: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
|
581
|
+
if (input.isEmpty() || channels <= 0) return FloatArray(0)
|
|
582
|
+
if (sourceRate == targetRate) return input
|
|
583
|
+
|
|
584
|
+
val ratio = sourceRate.toDouble() / targetRate.toDouble()
|
|
585
|
+
val srcFrames = input.size / channels
|
|
586
|
+
val previousFrame = lastSrcFrame
|
|
587
|
+
val totalSrcFrames = srcFrames + if (previousFrame != null) 1 else 0
|
|
588
|
+
if (totalSrcFrames < 2) {
|
|
589
|
+
// Not enough to interpolate yet; stash and return empty.
|
|
590
|
+
lastSrcFrame = FloatArray(channels).also { dst ->
|
|
591
|
+
if (srcFrames >= 1) {
|
|
592
|
+
System.arraycopy(input, (srcFrames - 1) * channels, dst, 0, channels)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return FloatArray(0)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Build virtual stream: [previousFrame?, input...].
|
|
599
|
+
val virtualLen = totalSrcFrames
|
|
600
|
+
val out = ArrayList<Float>(((virtualLen * targetRate) / sourceRate + 1) * channels)
|
|
601
|
+
var srcPos = fractional
|
|
602
|
+
while (srcPos < virtualLen - 1) {
|
|
603
|
+
val i = srcPos.toInt()
|
|
604
|
+
val frac = (srcPos - i).toFloat()
|
|
605
|
+
for (c in 0 until channels) {
|
|
606
|
+
val a = sampleAt(i, c, previousFrame, input)
|
|
607
|
+
val b = sampleAt(i + 1, c, previousFrame, input)
|
|
608
|
+
out.add(a + (b - a) * frac)
|
|
609
|
+
}
|
|
610
|
+
srcPos += ratio
|
|
611
|
+
}
|
|
612
|
+
fractional = srcPos - (virtualLen - 1)
|
|
613
|
+
|
|
614
|
+
// Stash last source frame for the next call.
|
|
615
|
+
lastSrcFrame = FloatArray(channels).also { dst ->
|
|
616
|
+
System.arraycopy(input, (srcFrames - 1) * channels, dst, 0, channels)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
val arr = FloatArray(out.size)
|
|
620
|
+
for (i in out.indices) arr[i] = out[i]
|
|
621
|
+
return arr
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private fun sampleAt(
|
|
625
|
+
frame: Int,
|
|
626
|
+
channel: Int,
|
|
627
|
+
previous: FloatArray?,
|
|
628
|
+
input: FloatArray,
|
|
629
|
+
): Float {
|
|
630
|
+
return if (previous != null) {
|
|
631
|
+
if (frame == 0) {
|
|
632
|
+
previous[channel]
|
|
633
|
+
} else {
|
|
634
|
+
input[(frame - 1) * channels + channel]
|
|
635
|
+
}
|
|
636
|
+
} else {
|
|
637
|
+
input[frame * channels + channel]
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|