@siteed/audio-studio 3.1.1 → 3.2.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 +375 -4
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +852 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +167 -3
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/errors/AudioStreamError.js +161 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +82 -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 +534 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- package/build/cjs/utils/audioProcessing.js +14 -10
- package/build/cjs/utils/audioProcessing.js.map +1 -1
- package/build/esm/errors/AudioStreamError.js +156 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +80 -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 +527 -0
- package/build/esm/streamAudioData.js.map +1 -0
- package/build/esm/utils/audioProcessing.js +14 -10
- package/build/esm/utils/audioProcessing.js.map +1 -1
- 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 +119 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/build/types/utils/audioProcessing.d.ts +2 -2
- package/build/types/utils/audioProcessing.d.ts.map +1 -1
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioStreamDecoder.swift +614 -0
- package/ios/AudioStudioModule.swift +186 -3
- package/package.json +163 -146
- package/scripts/README.md +58 -0
- package/src/errors/AudioStreamError.test.ts +92 -0
- package/src/errors/AudioStreamError.ts +199 -0
- package/src/index.ts +24 -0
- package/src/streamAudioData.ts +758 -0
- package/src/utils/audioProcessing.ts +25 -14
- package/android/src/androidTest/assets/chorus.wav +0 -0
- package/android/src/androidTest/assets/jfk.wav +0 -0
- package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
- package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
- package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
- package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
- package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +0 -94
- package/android/src/test/resources/jfk.wav +0 -0
- package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
- package/android/src/test/resources/recorder_hello_world.wav +0 -0
- package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
- package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
- package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
- package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
- package/ios/AudioStudioTests/Info.plist +0 -22
- package/ios/AudioStudioTests/README.md +0 -39
- package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
- package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
- package/ios/tests/README.md +0 -41
- package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
- package/ios/tests/integration/buffer_duration_test.swift +0 -185
- package/ios/tests/integration/compressed_only_output_test.swift +0 -271
- package/ios/tests/integration/output_control_test.swift +0 -322
- package/ios/tests/integration/run_integration_tests.sh +0 -37
- package/ios/tests/opus_support_test_macos.swift +0 -154
- package/ios/tests/standalone/audio_processing_test.swift +0 -144
- package/ios/tests/standalone/audio_recording_test.swift +0 -277
- package/ios/tests/standalone/audio_streaming_test.swift +0 -249
- package/ios/tests/standalone/standalone_test.swift +0 -144
|
@@ -0,0 +1,852 @@
|
|
|
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.media.MediaMetadataRetriever
|
|
15
|
+
import android.net.Uri
|
|
16
|
+
import android.os.Bundle
|
|
17
|
+
import androidx.core.os.bundleOf
|
|
18
|
+
import java.io.File
|
|
19
|
+
import java.nio.ByteBuffer
|
|
20
|
+
import java.nio.ByteOrder
|
|
21
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
22
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
23
|
+
import kotlin.concurrent.thread
|
|
24
|
+
import kotlin.math.ceil
|
|
25
|
+
|
|
26
|
+
interface AudioStreamDecoderDelegate {
|
|
27
|
+
fun streamDecoderEmit(eventName: String, payload: Bundle)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class AudioStreamDecoder(
|
|
31
|
+
private val context: Context,
|
|
32
|
+
private val options: Options,
|
|
33
|
+
private val delegate: AudioStreamDecoderDelegate,
|
|
34
|
+
) {
|
|
35
|
+
data class Options(
|
|
36
|
+
val requestId: String,
|
|
37
|
+
val fileUri: String,
|
|
38
|
+
val startTimeMs: Long?,
|
|
39
|
+
val endTimeMs: Long?,
|
|
40
|
+
val targetSampleRate: Int?,
|
|
41
|
+
val channels: Int?,
|
|
42
|
+
val normalizeAudio: Boolean,
|
|
43
|
+
val chunkDurationMs: Int,
|
|
44
|
+
val maxChunkBytes: Int?,
|
|
45
|
+
val maxBufferedChunks: Int,
|
|
46
|
+
val backpressureTimeoutMs: Long?,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
companion object {
|
|
50
|
+
private const val TAG = "AudioStreamDecoder"
|
|
51
|
+
private const val TIMEOUT_US = 10_000L
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private val cancelled = AtomicBoolean(false)
|
|
55
|
+
private val lastAckedIndex = AtomicInteger(-1)
|
|
56
|
+
private val ackLock = Object()
|
|
57
|
+
private var workerThread: Thread? = null
|
|
58
|
+
|
|
59
|
+
fun start() {
|
|
60
|
+
workerThread = thread(
|
|
61
|
+
isDaemon = true,
|
|
62
|
+
name = "AudioStreamDecoder-${options.requestId}"
|
|
63
|
+
) {
|
|
64
|
+
run()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun cancel() {
|
|
69
|
+
cancelled.set(true)
|
|
70
|
+
synchronized(ackLock) { ackLock.notifyAll() }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun acknowledgeChunk(index: Int) {
|
|
74
|
+
synchronized(ackLock) {
|
|
75
|
+
if (index > lastAckedIndex.get()) {
|
|
76
|
+
lastAckedIndex.set(index)
|
|
77
|
+
}
|
|
78
|
+
ackLock.notifyAll()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private fun run() {
|
|
83
|
+
val resolved = resolveFilePath(options.fileUri)
|
|
84
|
+
if (resolved == null) {
|
|
85
|
+
emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "Cannot resolve file: ${options.fileUri}")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
val path = resolved.path
|
|
89
|
+
val tempFile = resolved.tempFile
|
|
90
|
+
if (!File(path).exists()) {
|
|
91
|
+
tempFile?.let { runCatching { it.delete() } }
|
|
92
|
+
emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "File not found: $path")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
val extractor = MediaExtractor()
|
|
97
|
+
var codec: MediaCodec? = null
|
|
98
|
+
var emittedChunks = 0
|
|
99
|
+
var emittedSamples = 0L
|
|
100
|
+
var backpressureTimedOut = false
|
|
101
|
+
var outputSampleRate = options.targetSampleRate ?: 0
|
|
102
|
+
var outputChannels = options.channels ?: 0
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
try {
|
|
106
|
+
extractor.setDataSource(path)
|
|
107
|
+
} catch (e: Exception) {
|
|
108
|
+
emitError(
|
|
109
|
+
"ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
110
|
+
"setDataSource failed: ${e.message}"
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
|
|
116
|
+
val format = extractor.getTrackFormat(idx)
|
|
117
|
+
format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
|
118
|
+
}
|
|
119
|
+
if (trackIndex == null) {
|
|
120
|
+
emitError(
|
|
121
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
122
|
+
"No audio track found"
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
extractor.selectTrack(trackIndex)
|
|
127
|
+
val format = extractor.getTrackFormat(trackIndex)
|
|
128
|
+
val extractorDurationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
|
129
|
+
format.getLong(MediaFormat.KEY_DURATION)
|
|
130
|
+
} else {
|
|
131
|
+
-1L
|
|
132
|
+
}
|
|
133
|
+
val metadataDurationMs = if (extractorDurationUs <= 0) {
|
|
134
|
+
readMetadataDurationMs(path)
|
|
135
|
+
} else {
|
|
136
|
+
-1L
|
|
137
|
+
}
|
|
138
|
+
val assetDurationUs = if (extractorDurationUs > 0) {
|
|
139
|
+
extractorDurationUs
|
|
140
|
+
} else if (metadataDurationMs > 0) {
|
|
141
|
+
metadataDurationMs * 1000L
|
|
142
|
+
} else {
|
|
143
|
+
-1L
|
|
144
|
+
}
|
|
145
|
+
val assetDurationMs = if (assetDurationUs > 0) {
|
|
146
|
+
assetDurationUs / 1000.0
|
|
147
|
+
} else {
|
|
148
|
+
0.0
|
|
149
|
+
}
|
|
150
|
+
// Duration of the *decoded range*, not the whole file, so progress
|
|
151
|
+
// and completion payloads match what the caller actually receives.
|
|
152
|
+
val rangeDurationMs = when {
|
|
153
|
+
options.startTimeMs != null && options.endTimeMs != null ->
|
|
154
|
+
(options.endTimeMs - options.startTimeMs).toDouble().coerceAtLeast(0.0)
|
|
155
|
+
options.endTimeMs != null -> options.endTimeMs.toDouble().coerceAtLeast(0.0)
|
|
156
|
+
options.startTimeMs != null ->
|
|
157
|
+
(assetDurationMs - options.startTimeMs).coerceAtLeast(0.0)
|
|
158
|
+
else -> assetDurationMs
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
var sourceSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
162
|
+
var sourceChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
163
|
+
if (outputSampleRate <= 0) outputSampleRate = sourceSampleRate
|
|
164
|
+
if (outputChannels <= 0) outputChannels = minOf(2, maxOf(1, sourceChannels))
|
|
165
|
+
val maxOutputSamples = options.endTimeMs?.let {
|
|
166
|
+
((rangeDurationMs / 1000.0) * outputSampleRate.toDouble() * outputChannels.toDouble())
|
|
167
|
+
.toLong()
|
|
168
|
+
.coerceAtLeast(0L)
|
|
169
|
+
} ?: Long.MAX_VALUE
|
|
170
|
+
|
|
171
|
+
options.startTimeMs?.let {
|
|
172
|
+
extractor.seekTo(it * 1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
val mime = format.getString(MediaFormat.KEY_MIME)
|
|
176
|
+
?: run {
|
|
177
|
+
emitError(
|
|
178
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
179
|
+
"Track has no MIME"
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
codec = try {
|
|
184
|
+
MediaCodec.createDecoderByType(mime)
|
|
185
|
+
} catch (e: Exception) {
|
|
186
|
+
emitError(
|
|
187
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
188
|
+
"No decoder for $mime: ${e.message}"
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
codec.configure(format, null, null, 0)
|
|
193
|
+
codec.start()
|
|
194
|
+
|
|
195
|
+
val endTimeUs = options.endTimeMs?.let { it * 1000L } ?: Long.MAX_VALUE
|
|
196
|
+
val rangeStartMs = options.startTimeMs ?: 0L
|
|
197
|
+
val targetStartUs = rangeStartMs * 1000L
|
|
198
|
+
val samplesPerChunk = run {
|
|
199
|
+
val byTime = (options.chunkDurationMs.toLong() *
|
|
200
|
+
outputSampleRate.toLong() / 1000L).toInt() * outputChannels
|
|
201
|
+
// Round byte-cap down to a multiple of `outputChannels` so a
|
|
202
|
+
// single interleaved frame is never split across chunks.
|
|
203
|
+
val byBytes = options.maxChunkBytes?.let {
|
|
204
|
+
(it / 4 / outputChannels) * outputChannels
|
|
205
|
+
} ?: Int.MAX_VALUE
|
|
206
|
+
val raw = minOf(byTime, byBytes)
|
|
207
|
+
maxOf(outputChannels, (raw / outputChannels) * outputChannels)
|
|
208
|
+
}
|
|
209
|
+
var pending = FloatArray(samplesPerChunk * 2)
|
|
210
|
+
var pendingLen = 0
|
|
211
|
+
var pendingHead = 0
|
|
212
|
+
fun pendingAvailable(): Int = pendingLen - pendingHead
|
|
213
|
+
fun compactPending() {
|
|
214
|
+
val available = pendingAvailable()
|
|
215
|
+
if (pendingHead == 0) return
|
|
216
|
+
if (available > 0) {
|
|
217
|
+
System.arraycopy(pending, pendingHead, pending, 0, available)
|
|
218
|
+
}
|
|
219
|
+
pendingHead = 0
|
|
220
|
+
pendingLen = available
|
|
221
|
+
}
|
|
222
|
+
fun appendPending(samples: FloatArray) {
|
|
223
|
+
if (samples.isEmpty()) return
|
|
224
|
+
val available = pendingAvailable()
|
|
225
|
+
val requiredLen = available + samples.size
|
|
226
|
+
if (requiredLen > pending.size) {
|
|
227
|
+
val grown = FloatArray(requiredLen.coerceAtLeast(pending.size * 2))
|
|
228
|
+
if (available > 0) {
|
|
229
|
+
System.arraycopy(pending, pendingHead, grown, 0, available)
|
|
230
|
+
}
|
|
231
|
+
pending = grown
|
|
232
|
+
pendingHead = 0
|
|
233
|
+
pendingLen = available
|
|
234
|
+
} else if (pending.size - pendingLen < samples.size && pendingHead > 0) {
|
|
235
|
+
compactPending()
|
|
236
|
+
}
|
|
237
|
+
System.arraycopy(samples, 0, pending, pendingLen, samples.size)
|
|
238
|
+
pendingLen += samples.size
|
|
239
|
+
}
|
|
240
|
+
val info = MediaCodec.BufferInfo()
|
|
241
|
+
var sawInputEOS = false
|
|
242
|
+
var sawOutputEOS = false
|
|
243
|
+
|
|
244
|
+
// Resampling state across output buffers. We keep the last source
|
|
245
|
+
// sample to interpolate across buffer boundaries.
|
|
246
|
+
val resampler = LinearResampler(outputChannels)
|
|
247
|
+
|
|
248
|
+
while (!sawOutputEOS) {
|
|
249
|
+
if (cancelled.get()) break
|
|
250
|
+
|
|
251
|
+
if (!sawInputEOS) {
|
|
252
|
+
val inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US)
|
|
253
|
+
if (inputBufferIndex >= 0) {
|
|
254
|
+
val inputBuffer = codec.getInputBuffer(inputBufferIndex)
|
|
255
|
+
if (inputBuffer != null) {
|
|
256
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
257
|
+
val sampleTime = extractor.sampleTime
|
|
258
|
+
if (sampleSize < 0 || sampleTime > endTimeUs) {
|
|
259
|
+
codec.queueInputBuffer(
|
|
260
|
+
inputBufferIndex,
|
|
261
|
+
0,
|
|
262
|
+
0,
|
|
263
|
+
0,
|
|
264
|
+
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
|
265
|
+
)
|
|
266
|
+
sawInputEOS = true
|
|
267
|
+
} else {
|
|
268
|
+
codec.queueInputBuffer(
|
|
269
|
+
inputBufferIndex,
|
|
270
|
+
0,
|
|
271
|
+
sampleSize,
|
|
272
|
+
sampleTime,
|
|
273
|
+
0
|
|
274
|
+
)
|
|
275
|
+
extractor.advance()
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
val outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US)
|
|
282
|
+
if (outputIndex >= 0) {
|
|
283
|
+
val outputBuffer = codec.getOutputBuffer(outputIndex)
|
|
284
|
+
if (outputBuffer != null && info.size > 0) {
|
|
285
|
+
outputBuffer.position(info.offset)
|
|
286
|
+
outputBuffer.limit(info.offset + info.size)
|
|
287
|
+
val pcm = ByteArray(info.size)
|
|
288
|
+
outputBuffer.get(pcm)
|
|
289
|
+
|
|
290
|
+
val converted = pcmToFloat(
|
|
291
|
+
pcm,
|
|
292
|
+
sourceChannels,
|
|
293
|
+
outputChannels,
|
|
294
|
+
options.normalizeAudio
|
|
295
|
+
)
|
|
296
|
+
// SEEK_TO_CLOSEST_SYNC can land before the requested
|
|
297
|
+
// start (encoder priming on AAC/MP3, sync-frame
|
|
298
|
+
// granularity on lossy containers). Trim source-rate
|
|
299
|
+
// frames whose presentation time is before
|
|
300
|
+
// `targetStartUs` so the first emitted sample lines
|
|
301
|
+
// up with `startTimeMs`.
|
|
302
|
+
val bufferStartUs = info.presentationTimeUs
|
|
303
|
+
val trimmed: FloatArray = if (
|
|
304
|
+
targetStartUs > 0 &&
|
|
305
|
+
bufferStartUs in 0L until targetStartUs &&
|
|
306
|
+
converted.isNotEmpty()
|
|
307
|
+
) {
|
|
308
|
+
val deltaUs = targetStartUs - bufferStartUs
|
|
309
|
+
val skipFrames = (
|
|
310
|
+
deltaUs.toDouble() * sourceSampleRate.toDouble() /
|
|
311
|
+
1_000_000.0
|
|
312
|
+
).toInt()
|
|
313
|
+
val totalFrames = converted.size / outputChannels
|
|
314
|
+
val actualSkip = minOf(skipFrames, totalFrames)
|
|
315
|
+
if (actualSkip >= totalFrames) {
|
|
316
|
+
FloatArray(0)
|
|
317
|
+
} else if (actualSkip > 0) {
|
|
318
|
+
val skipFloats = actualSkip * outputChannels
|
|
319
|
+
val out = FloatArray(converted.size - skipFloats)
|
|
320
|
+
System.arraycopy(
|
|
321
|
+
converted,
|
|
322
|
+
skipFloats,
|
|
323
|
+
out,
|
|
324
|
+
0,
|
|
325
|
+
out.size
|
|
326
|
+
)
|
|
327
|
+
out
|
|
328
|
+
} else {
|
|
329
|
+
converted
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
converted
|
|
333
|
+
}
|
|
334
|
+
val resampled = resampler.process(
|
|
335
|
+
trimmed,
|
|
336
|
+
sourceSampleRate,
|
|
337
|
+
outputSampleRate
|
|
338
|
+
)
|
|
339
|
+
val remainingOutputSamples =
|
|
340
|
+
maxOutputSamples - emittedSamples - pendingAvailable().toLong()
|
|
341
|
+
val boundedResampled = when {
|
|
342
|
+
remainingOutputSamples <= 0L -> FloatArray(0)
|
|
343
|
+
resampled.size.toLong() > remainingOutputSamples -> {
|
|
344
|
+
val boundedSize = (
|
|
345
|
+
remainingOutputSamples.toInt() / outputChannels
|
|
346
|
+
) * outputChannels
|
|
347
|
+
resampled.copyOf(boundedSize)
|
|
348
|
+
}
|
|
349
|
+
else -> resampled
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
appendPending(boundedResampled)
|
|
353
|
+
|
|
354
|
+
chunkLoop@ while (pendingAvailable() >= samplesPerChunk) {
|
|
355
|
+
if (cancelled.get()) break
|
|
356
|
+
val chunk = FloatArray(samplesPerChunk)
|
|
357
|
+
System.arraycopy(pending, pendingHead, chunk, 0, samplesPerChunk)
|
|
358
|
+
pendingHead += samplesPerChunk
|
|
359
|
+
if (pendingHead > pending.size / 2) {
|
|
360
|
+
compactPending()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
val chunkDurationMs =
|
|
364
|
+
(chunk.size.toDouble() /
|
|
365
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
366
|
+
1000.0
|
|
367
|
+
val startMs = rangeStartMs +
|
|
368
|
+
(emittedSamples.toDouble() /
|
|
369
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
370
|
+
1000.0
|
|
371
|
+
emitChunk(
|
|
372
|
+
index = emittedChunks,
|
|
373
|
+
startTimeMs = startMs,
|
|
374
|
+
endTimeMs = startMs + chunkDurationMs,
|
|
375
|
+
startSample = emittedSamples / outputChannels,
|
|
376
|
+
sampleRate = outputSampleRate,
|
|
377
|
+
channels = outputChannels,
|
|
378
|
+
samples = chunk,
|
|
379
|
+
isFinal = false
|
|
380
|
+
)
|
|
381
|
+
emittedChunks += 1
|
|
382
|
+
emittedSamples += chunk.size
|
|
383
|
+
// Progress is elapsed decoded time within the
|
|
384
|
+
// requested range so `processedMs / durationMs`
|
|
385
|
+
// stays in [0, 1] regardless of `startTimeMs`.
|
|
386
|
+
// Chunk timestamps stay absolute (rangeStart +
|
|
387
|
+
// offset).
|
|
388
|
+
val elapsedMs = (emittedSamples.toDouble() /
|
|
389
|
+
(outputSampleRate.toDouble() *
|
|
390
|
+
outputChannels.toDouble())) * 1000.0
|
|
391
|
+
emitProgress(
|
|
392
|
+
processedMs = elapsedMs,
|
|
393
|
+
durationMs = rangeDurationMs,
|
|
394
|
+
emittedChunks = emittedChunks
|
|
395
|
+
)
|
|
396
|
+
when (waitForAckOrCancel(emittedChunks - 1)) {
|
|
397
|
+
AckWaitResult.OK -> Unit
|
|
398
|
+
AckWaitResult.CANCELLED -> {
|
|
399
|
+
cancelled.set(true)
|
|
400
|
+
break@chunkLoop
|
|
401
|
+
}
|
|
402
|
+
AckWaitResult.TIMED_OUT -> {
|
|
403
|
+
backpressureTimedOut = true
|
|
404
|
+
break@chunkLoop
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
codec.releaseOutputBuffer(outputIndex, false)
|
|
410
|
+
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
411
|
+
sawOutputEOS = true
|
|
412
|
+
}
|
|
413
|
+
if (backpressureTimedOut) {
|
|
414
|
+
break
|
|
415
|
+
}
|
|
416
|
+
} else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
|
417
|
+
val newFormat = codec.outputFormat
|
|
418
|
+
// Codec output format is authoritative once decoding starts;
|
|
419
|
+
// the resampler is told the new source rate on its next call
|
|
420
|
+
// so the output rate (and chunk timestamps) stay correct.
|
|
421
|
+
if (newFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
|
|
422
|
+
sourceSampleRate = newFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
423
|
+
}
|
|
424
|
+
if (newFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
|
|
425
|
+
sourceChannels = newFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!cancelled.get() && !backpressureTimedOut) {
|
|
431
|
+
val resamplerTail = resampler.flush()
|
|
432
|
+
if (resamplerTail.isNotEmpty()) {
|
|
433
|
+
appendPending(resamplerTail)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (backpressureTimedOut) {
|
|
438
|
+
emitError(
|
|
439
|
+
"ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT",
|
|
440
|
+
"Timed out waiting for JS acknowledgement after ${options.backpressureTimeoutMs}ms"
|
|
441
|
+
)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (cancelled.get()) {
|
|
446
|
+
emitError("ERR_AUDIO_STREAM_CANCELLED", "Stream cancelled")
|
|
447
|
+
emitComplete(
|
|
448
|
+
durationMs = rangeDurationMs,
|
|
449
|
+
sampleRate = outputSampleRate,
|
|
450
|
+
channels = outputChannels,
|
|
451
|
+
chunks = emittedChunks,
|
|
452
|
+
samples = emittedSamples,
|
|
453
|
+
cancelled = true
|
|
454
|
+
)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Flush remainder as final chunk
|
|
459
|
+
if (pendingAvailable() > 0) {
|
|
460
|
+
val tail = FloatArray(pendingAvailable())
|
|
461
|
+
System.arraycopy(pending, pendingHead, tail, 0, tail.size)
|
|
462
|
+
val tailDurationMs =
|
|
463
|
+
(tail.size.toDouble() /
|
|
464
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
465
|
+
1000.0
|
|
466
|
+
val startMs = rangeStartMs +
|
|
467
|
+
(emittedSamples.toDouble() /
|
|
468
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
469
|
+
1000.0
|
|
470
|
+
emitChunk(
|
|
471
|
+
index = emittedChunks,
|
|
472
|
+
startTimeMs = startMs,
|
|
473
|
+
endTimeMs = startMs + tailDurationMs,
|
|
474
|
+
startSample = emittedSamples / outputChannels,
|
|
475
|
+
sampleRate = outputSampleRate,
|
|
476
|
+
channels = outputChannels,
|
|
477
|
+
samples = tail,
|
|
478
|
+
isFinal = true
|
|
479
|
+
)
|
|
480
|
+
emittedChunks += 1
|
|
481
|
+
emittedSamples += tail.size
|
|
482
|
+
// Mirror the per-chunk progress emission so consumers always
|
|
483
|
+
// see a final `processedMs / durationMs ≈ 1.0` from
|
|
484
|
+
// `onProgress`.
|
|
485
|
+
val elapsedMs = (emittedSamples.toDouble() /
|
|
486
|
+
(outputSampleRate.toDouble() *
|
|
487
|
+
outputChannels.toDouble())) * 1000.0
|
|
488
|
+
emitProgress(
|
|
489
|
+
processedMs = elapsedMs,
|
|
490
|
+
durationMs = rangeDurationMs,
|
|
491
|
+
emittedChunks = emittedChunks
|
|
492
|
+
)
|
|
493
|
+
} else {
|
|
494
|
+
emitChunk(
|
|
495
|
+
index = emittedChunks,
|
|
496
|
+
startTimeMs = rangeStartMs +
|
|
497
|
+
(emittedSamples.toDouble() /
|
|
498
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
499
|
+
1000.0,
|
|
500
|
+
endTimeMs = rangeStartMs +
|
|
501
|
+
(emittedSamples.toDouble() /
|
|
502
|
+
(outputSampleRate.toDouble() * outputChannels.toDouble())) *
|
|
503
|
+
1000.0,
|
|
504
|
+
startSample = emittedSamples / outputChannels,
|
|
505
|
+
sampleRate = outputSampleRate,
|
|
506
|
+
channels = outputChannels,
|
|
507
|
+
samples = FloatArray(0),
|
|
508
|
+
isFinal = true
|
|
509
|
+
)
|
|
510
|
+
emittedChunks += 1
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
emitComplete(
|
|
514
|
+
durationMs = rangeDurationMs,
|
|
515
|
+
sampleRate = outputSampleRate,
|
|
516
|
+
channels = outputChannels,
|
|
517
|
+
chunks = emittedChunks,
|
|
518
|
+
samples = emittedSamples,
|
|
519
|
+
cancelled = false
|
|
520
|
+
)
|
|
521
|
+
} catch (e: Exception) {
|
|
522
|
+
LogUtils.e(TAG, "Decode failed: ${e.message}", e)
|
|
523
|
+
emitError(
|
|
524
|
+
"ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
525
|
+
e.message ?: "Unknown decode error"
|
|
526
|
+
)
|
|
527
|
+
} finally {
|
|
528
|
+
try {
|
|
529
|
+
codec?.stop()
|
|
530
|
+
} catch (_: Exception) { /* noop */ }
|
|
531
|
+
try {
|
|
532
|
+
codec?.release()
|
|
533
|
+
} catch (_: Exception) { /* noop */ }
|
|
534
|
+
try {
|
|
535
|
+
extractor.release()
|
|
536
|
+
} catch (_: Exception) { /* noop */ }
|
|
537
|
+
tempFile?.let { runCatching { it.delete() } }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private data class ResolvedFile(val path: String, val tempFile: File?)
|
|
542
|
+
|
|
543
|
+
private fun readMetadataDurationMs(path: String): Long {
|
|
544
|
+
return try {
|
|
545
|
+
val retriever = MediaMetadataRetriever()
|
|
546
|
+
try {
|
|
547
|
+
retriever.setDataSource(path)
|
|
548
|
+
retriever
|
|
549
|
+
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
|
550
|
+
?.toLongOrNull()
|
|
551
|
+
?: -1L
|
|
552
|
+
} finally {
|
|
553
|
+
retriever.release()
|
|
554
|
+
}
|
|
555
|
+
} catch (_: Exception) {
|
|
556
|
+
-1L
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private fun resolveFilePath(uri: String): ResolvedFile? {
|
|
561
|
+
if (uri.startsWith("/")) return ResolvedFile(uri, null)
|
|
562
|
+
return try {
|
|
563
|
+
val parsed = Uri.parse(uri)
|
|
564
|
+
when (parsed.scheme) {
|
|
565
|
+
"file" -> parsed.path?.let { ResolvedFile(it, null) }
|
|
566
|
+
"content" -> {
|
|
567
|
+
// MediaExtractor needs a real file path; copy the content
|
|
568
|
+
// URI into the cache dir and remember the temp file so we
|
|
569
|
+
// can delete it in `finally` (otherwise every call leaks a
|
|
570
|
+
// copy of the source audio onto disk).
|
|
571
|
+
val temp = File.createTempFile(
|
|
572
|
+
"audiostream_${options.requestId}_",
|
|
573
|
+
null,
|
|
574
|
+
context.cacheDir
|
|
575
|
+
)
|
|
576
|
+
context.contentResolver.openInputStream(parsed)?.use { input ->
|
|
577
|
+
temp.outputStream().use { out -> input.copyTo(out) }
|
|
578
|
+
}
|
|
579
|
+
ResolvedFile(temp.absolutePath, temp)
|
|
580
|
+
}
|
|
581
|
+
null -> ResolvedFile(uri, null)
|
|
582
|
+
else -> null
|
|
583
|
+
}
|
|
584
|
+
} catch (e: Exception) {
|
|
585
|
+
LogUtils.e(TAG, "resolveFilePath failed: ${e.message}", e)
|
|
586
|
+
null
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private fun pcmToFloat(
|
|
591
|
+
pcm: ByteArray,
|
|
592
|
+
sourceChannels: Int,
|
|
593
|
+
targetChannels: Int,
|
|
594
|
+
normalize: Boolean,
|
|
595
|
+
): FloatArray {
|
|
596
|
+
if (pcm.isEmpty() || sourceChannels <= 0) return FloatArray(0)
|
|
597
|
+
val buffer = ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN)
|
|
598
|
+
val totalShorts = pcm.size / 2
|
|
599
|
+
val srcFrames = totalShorts / sourceChannels
|
|
600
|
+
val out = FloatArray(srcFrames * targetChannels)
|
|
601
|
+
var outIdx = 0
|
|
602
|
+
val tempFrame = FloatArray(sourceChannels)
|
|
603
|
+
for (frame in 0 until srcFrames) {
|
|
604
|
+
for (c in 0 until sourceChannels) {
|
|
605
|
+
val s = buffer.short.toFloat() / 32768.0f
|
|
606
|
+
val safe = if (s.isFinite()) s else 0f
|
|
607
|
+
tempFrame[c] = if (normalize) {
|
|
608
|
+
when {
|
|
609
|
+
safe > 1f -> 1f
|
|
610
|
+
safe < -1f -> -1f
|
|
611
|
+
else -> safe
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
safe
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
when {
|
|
618
|
+
sourceChannels == targetChannels -> {
|
|
619
|
+
for (c in 0 until targetChannels) {
|
|
620
|
+
out[outIdx++] = tempFrame[c]
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
targetChannels == 1 -> {
|
|
624
|
+
// Downmix average
|
|
625
|
+
var sum = 0f
|
|
626
|
+
for (c in 0 until sourceChannels) sum += tempFrame[c]
|
|
627
|
+
out[outIdx++] = sum / sourceChannels
|
|
628
|
+
}
|
|
629
|
+
sourceChannels == 1 -> {
|
|
630
|
+
// Upmix mono -> N (duplicate)
|
|
631
|
+
for (c in 0 until targetChannels) {
|
|
632
|
+
out[outIdx++] = tempFrame[0]
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
else -> {
|
|
636
|
+
// Drop or duplicate channels; for simplicity take first N or zero-pad.
|
|
637
|
+
for (c in 0 until targetChannels) {
|
|
638
|
+
out[outIdx++] = if (c < sourceChannels) tempFrame[c] else 0f
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return out
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private enum class AckWaitResult { OK, CANCELLED, TIMED_OUT }
|
|
647
|
+
|
|
648
|
+
private fun waitForAckOrCancel(emittedIndex: Int): AckWaitResult {
|
|
649
|
+
val deadlineMs = options.backpressureTimeoutMs
|
|
650
|
+
?.takeIf { it > 0 }
|
|
651
|
+
?.let { System.currentTimeMillis() + it }
|
|
652
|
+
synchronized(ackLock) {
|
|
653
|
+
while (true) {
|
|
654
|
+
if (cancelled.get()) return AckWaitResult.CANCELLED
|
|
655
|
+
val inFlight = emittedIndex - lastAckedIndex.get()
|
|
656
|
+
if (inFlight < options.maxBufferedChunks) return AckWaitResult.OK
|
|
657
|
+
val remainingMs = deadlineMs?.let { it - System.currentTimeMillis() }
|
|
658
|
+
if (remainingMs != null && remainingMs <= 0L) {
|
|
659
|
+
return AckWaitResult.TIMED_OUT
|
|
660
|
+
}
|
|
661
|
+
ackLock.wait(minOf(50L, remainingMs ?: 50L))
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private fun emitChunk(
|
|
667
|
+
index: Int,
|
|
668
|
+
startTimeMs: Double,
|
|
669
|
+
endTimeMs: Double,
|
|
670
|
+
startSample: Long,
|
|
671
|
+
sampleRate: Int,
|
|
672
|
+
channels: Int,
|
|
673
|
+
samples: FloatArray,
|
|
674
|
+
isFinal: Boolean,
|
|
675
|
+
) {
|
|
676
|
+
// Pass FloatArray directly; expo-modules-core converts to JS array.
|
|
677
|
+
val payload = bundleOf(
|
|
678
|
+
"requestId" to options.requestId,
|
|
679
|
+
"chunkIndex" to index,
|
|
680
|
+
"startTimeMs" to startTimeMs,
|
|
681
|
+
"endTimeMs" to endTimeMs,
|
|
682
|
+
"startSample" to startSample,
|
|
683
|
+
"sampleCount" to samples.size,
|
|
684
|
+
"sampleRate" to sampleRate,
|
|
685
|
+
"channels" to channels,
|
|
686
|
+
"samples" to samples,
|
|
687
|
+
"isFinal" to isFinal,
|
|
688
|
+
)
|
|
689
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_CHUNK_EVENT, payload)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private fun emitProgress(
|
|
693
|
+
processedMs: Double,
|
|
694
|
+
durationMs: Double,
|
|
695
|
+
emittedChunks: Int,
|
|
696
|
+
) {
|
|
697
|
+
val progress = if (durationMs > 0) {
|
|
698
|
+
(processedMs / durationMs).coerceIn(0.0, 1.0)
|
|
699
|
+
} else {
|
|
700
|
+
0.0
|
|
701
|
+
}
|
|
702
|
+
val payload = bundleOf(
|
|
703
|
+
"requestId" to options.requestId,
|
|
704
|
+
"processedMs" to processedMs,
|
|
705
|
+
"durationMs" to durationMs,
|
|
706
|
+
"progress" to progress,
|
|
707
|
+
"emittedChunks" to emittedChunks,
|
|
708
|
+
)
|
|
709
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_PROGRESS_EVENT, payload)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private fun emitComplete(
|
|
713
|
+
durationMs: Double,
|
|
714
|
+
sampleRate: Int,
|
|
715
|
+
channels: Int,
|
|
716
|
+
chunks: Int,
|
|
717
|
+
samples: Long,
|
|
718
|
+
cancelled: Boolean,
|
|
719
|
+
) {
|
|
720
|
+
val payload = bundleOf(
|
|
721
|
+
"requestId" to options.requestId,
|
|
722
|
+
"durationMs" to durationMs,
|
|
723
|
+
"sampleRate" to sampleRate,
|
|
724
|
+
"channels" to channels,
|
|
725
|
+
"chunks" to chunks,
|
|
726
|
+
"samples" to samples,
|
|
727
|
+
"cancelled" to cancelled,
|
|
728
|
+
)
|
|
729
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_COMPLETE_EVENT, payload)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private fun emitError(code: String, message: String) {
|
|
733
|
+
val payload = bundleOf(
|
|
734
|
+
"requestId" to options.requestId,
|
|
735
|
+
"code" to code,
|
|
736
|
+
"message" to message,
|
|
737
|
+
)
|
|
738
|
+
delegate.streamDecoderEmit(Constants.AUDIO_STREAM_ERROR_EVENT, payload)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Per-stream linear resampler keyed by output channel count. Maintains the
|
|
744
|
+
* last source frame across buffer boundaries so resampling is continuous.
|
|
745
|
+
*/
|
|
746
|
+
private class LinearResampler(private val channels: Int) {
|
|
747
|
+
private var lastSrcFrame: FloatArray? = null
|
|
748
|
+
private var fractional: Double = 0.0
|
|
749
|
+
private var lastSourceRate: Int? = null
|
|
750
|
+
private var lastTargetRate: Int? = null
|
|
751
|
+
private var canFlushLastFrame = false
|
|
752
|
+
|
|
753
|
+
fun process(input: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
|
754
|
+
if (input.isEmpty() || channels <= 0) return FloatArray(0)
|
|
755
|
+
val srcFrames = input.size / channels
|
|
756
|
+
if (srcFrames <= 0) return FloatArray(0)
|
|
757
|
+
|
|
758
|
+
if (lastSourceRate != null &&
|
|
759
|
+
(lastSourceRate != sourceRate || lastTargetRate != targetRate)
|
|
760
|
+
) {
|
|
761
|
+
reset()
|
|
762
|
+
}
|
|
763
|
+
lastSourceRate = sourceRate
|
|
764
|
+
lastTargetRate = targetRate
|
|
765
|
+
|
|
766
|
+
if (sourceRate == targetRate) {
|
|
767
|
+
stashLastFrame(input, srcFrames)
|
|
768
|
+
fractional = 0.0
|
|
769
|
+
canFlushLastFrame = false
|
|
770
|
+
return input
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
val ratio = sourceRate.toDouble() / targetRate.toDouble()
|
|
774
|
+
val previousFrame = lastSrcFrame
|
|
775
|
+
val totalSrcFrames = srcFrames + if (previousFrame != null) 1 else 0
|
|
776
|
+
if (totalSrcFrames < 2) {
|
|
777
|
+
// Not enough to interpolate yet; stash and return empty.
|
|
778
|
+
stashLastFrame(input, srcFrames)
|
|
779
|
+
canFlushLastFrame = true
|
|
780
|
+
return FloatArray(0)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Build virtual stream: [previousFrame?, input...].
|
|
784
|
+
val virtualLen = totalSrcFrames
|
|
785
|
+
val outFrames = ceil(((virtualLen - 1) - fractional) / ratio)
|
|
786
|
+
.toInt()
|
|
787
|
+
.coerceAtLeast(0)
|
|
788
|
+
// Add one frame of slack for floating-point boundary rounding; trim
|
|
789
|
+
// below if the conservative capacity was not used.
|
|
790
|
+
val out = FloatArray((outFrames + 1) * channels)
|
|
791
|
+
var outIdx = 0
|
|
792
|
+
var srcPos = fractional
|
|
793
|
+
while (srcPos < virtualLen - 1) {
|
|
794
|
+
val i = srcPos.toInt()
|
|
795
|
+
val frac = (srcPos - i).toFloat()
|
|
796
|
+
for (c in 0 until channels) {
|
|
797
|
+
val a = sampleAt(i, c, previousFrame, input)
|
|
798
|
+
val b = sampleAt(i + 1, c, previousFrame, input)
|
|
799
|
+
out[outIdx++] = a + (b - a) * frac
|
|
800
|
+
}
|
|
801
|
+
srcPos += ratio
|
|
802
|
+
}
|
|
803
|
+
fractional = srcPos - (virtualLen - 1)
|
|
804
|
+
|
|
805
|
+
// Stash last source frame for the next call. The interpolation loop
|
|
806
|
+
// intentionally stops before the final frame so that it can interpolate
|
|
807
|
+
// across the next decoder buffer; flush() emits this tail at EOS.
|
|
808
|
+
stashLastFrame(input, srcFrames)
|
|
809
|
+
canFlushLastFrame = true
|
|
810
|
+
|
|
811
|
+
return if (outIdx == out.size) out else out.copyOf(outIdx)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
fun flush(): FloatArray {
|
|
815
|
+
val tail = if (canFlushLastFrame) {
|
|
816
|
+
lastSrcFrame?.copyOf() ?: FloatArray(0)
|
|
817
|
+
} else {
|
|
818
|
+
FloatArray(0)
|
|
819
|
+
}
|
|
820
|
+
reset()
|
|
821
|
+
return tail
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private fun reset() {
|
|
825
|
+
lastSrcFrame = null
|
|
826
|
+
fractional = 0.0
|
|
827
|
+
canFlushLastFrame = false
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private fun stashLastFrame(input: FloatArray, srcFrames: Int) {
|
|
831
|
+
lastSrcFrame = FloatArray(channels).also { dst ->
|
|
832
|
+
System.arraycopy(input, (srcFrames - 1) * channels, dst, 0, channels)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private fun sampleAt(
|
|
837
|
+
frame: Int,
|
|
838
|
+
channel: Int,
|
|
839
|
+
previous: FloatArray?,
|
|
840
|
+
input: FloatArray,
|
|
841
|
+
): Float {
|
|
842
|
+
return if (previous != null) {
|
|
843
|
+
if (frame == 0) {
|
|
844
|
+
previous[channel]
|
|
845
|
+
} else {
|
|
846
|
+
input[(frame - 1) * channels + channel]
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
input[frame * channels + channel]
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|