@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +375 -4
  2. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +852 -0
  3. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +167 -3
  4. package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
  5. package/build/cjs/errors/AudioStreamError.js +161 -0
  6. package/build/cjs/errors/AudioStreamError.js.map +1 -0
  7. package/build/cjs/errors/AudioStreamError.test.js +82 -0
  8. package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
  9. package/build/cjs/index.js +7 -1
  10. package/build/cjs/index.js.map +1 -1
  11. package/build/cjs/streamAudioData.js +534 -0
  12. package/build/cjs/streamAudioData.js.map +1 -0
  13. package/build/cjs/utils/audioProcessing.js +14 -10
  14. package/build/cjs/utils/audioProcessing.js.map +1 -1
  15. package/build/esm/errors/AudioStreamError.js +156 -0
  16. package/build/esm/errors/AudioStreamError.js.map +1 -0
  17. package/build/esm/errors/AudioStreamError.test.js +80 -0
  18. package/build/esm/errors/AudioStreamError.test.js.map +1 -0
  19. package/build/esm/index.js +3 -1
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/streamAudioData.js +527 -0
  22. package/build/esm/streamAudioData.js.map +1 -0
  23. package/build/esm/utils/audioProcessing.js +14 -10
  24. package/build/esm/utils/audioProcessing.js.map +1 -1
  25. package/build/types/errors/AudioStreamError.d.ts +25 -0
  26. package/build/types/errors/AudioStreamError.d.ts.map +1 -0
  27. package/build/types/errors/AudioStreamError.test.d.ts +2 -0
  28. package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
  29. package/build/types/index.d.ts +5 -1
  30. package/build/types/index.d.ts.map +1 -1
  31. package/build/types/streamAudioData.d.ts +119 -0
  32. package/build/types/streamAudioData.d.ts.map +1 -0
  33. package/build/types/utils/audioProcessing.d.ts +2 -2
  34. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  35. package/ios/AudioProcessingHelpers.swift +10 -5
  36. package/ios/AudioStreamDecoder.swift +614 -0
  37. package/ios/AudioStudioModule.swift +186 -3
  38. package/package.json +163 -146
  39. package/scripts/README.md +58 -0
  40. package/src/errors/AudioStreamError.test.ts +92 -0
  41. package/src/errors/AudioStreamError.ts +199 -0
  42. package/src/index.ts +24 -0
  43. package/src/streamAudioData.ts +758 -0
  44. package/src/utils/audioProcessing.ts +25 -14
  45. package/android/src/androidTest/assets/chorus.wav +0 -0
  46. package/android/src/androidTest/assets/jfk.wav +0 -0
  47. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  48. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  49. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  50. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  51. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  52. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  53. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  54. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  55. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  56. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  57. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  58. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  59. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  60. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  61. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  62. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  63. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  64. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  65. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  66. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  67. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  68. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  69. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  70. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  71. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  72. package/android/src/test/resources/chorus.wav +0 -0
  73. package/android/src/test/resources/generate_test_audio.py +0 -94
  74. package/android/src/test/resources/jfk.wav +0 -0
  75. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  76. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  77. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  78. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  79. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  80. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  81. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  82. package/ios/AudioStudioTests/Info.plist +0 -22
  83. package/ios/AudioStudioTests/README.md +0 -39
  84. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  85. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  86. package/ios/tests/README.md +0 -41
  87. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  88. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  89. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  90. package/ios/tests/integration/output_control_test.swift +0 -322
  91. package/ios/tests/integration/run_integration_tests.sh +0 -37
  92. package/ios/tests/opus_support_test_macos.swift +0 -154
  93. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  94. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  95. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  96. package/ios/tests/standalone/standalone_test.swift +0 -144
@@ -0,0 +1,758 @@
1
+ import { LegacyEventEmitter, type EventSubscription } from 'expo-modules-core'
2
+
3
+ import AudioStudioModule from './AudioStudioModule'
4
+ import { isWeb } from './constants'
5
+ import {
6
+ AudioStreamError,
7
+ AudioStreamErrorCode,
8
+ mapStreamError,
9
+ } from './errors/AudioStreamError'
10
+ import { processAudioBuffer } from './utils/audioProcessing'
11
+
12
+ /**
13
+ * High-level API: stream decoded audio from a stored file as bounded Float32
14
+ * chunks without materializing the full PCM range in memory.
15
+ *
16
+ * See `docs/STREAM_AUDIO_DATA.md` for the full contract and rollout notes.
17
+ */
18
+ export interface StreamAudioDataOptions {
19
+ /** URI of the audio file to decode. */
20
+ fileUri: string
21
+ /** Start time in milliseconds (default: 0). */
22
+ startTimeMs?: number
23
+ /** End time in milliseconds (default: end-of-file). */
24
+ endTimeMs?: number
25
+ /**
26
+ * Source sample rate hint. Ignored if `targetSampleRate` is set; native
27
+ * decoders read the actual rate from the file.
28
+ */
29
+ sampleRate?: number
30
+ /** Output sample rate. Native resamples when this differs from the file. */
31
+ targetSampleRate?: number
32
+ /** Output channel count (1 = mono downmix, 2 = stereo passthrough). */
33
+ channels?: number
34
+ /** Clamp samples to [-1, 1] and replace non-finite values with 0. */
35
+ normalizeAudio?: boolean
36
+ /** Target chunk duration in ms (default: 1000, min: 10, max: 60000). */
37
+ chunkDurationMs?: number
38
+ /** Soft cap on chunk size in bytes (Float32 = 4 bytes/sample). */
39
+ maxChunkBytes?: number
40
+ /** Max chunks queued in native before JS ack pauses decode (default: 4). */
41
+ maxBufferedChunks?: number
42
+ /**
43
+ * Optional timeout for a chunk acknowledgement while backpressure is active.
44
+ * Undefined/0 disables timeout so long transcription callbacks can run.
45
+ */
46
+ backpressureTimeoutMs?: number
47
+ /** Output PCM format; only `'float32'` supported today. */
48
+ streamFormat?: 'float32'
49
+ /** Abort the in-flight request. Resolves promise with `cancelled: true`. */
50
+ signal?: AbortSignal
51
+ }
52
+
53
+ export interface StreamAudioDataChunk {
54
+ /** Native request id; constant across all chunks of one call. */
55
+ requestId: string
56
+ /** Zero-based monotonic chunk index. */
57
+ chunkIndex: number
58
+ /** Start time in output-rate ms (rounded to nearest sample). */
59
+ startTimeMs: number
60
+ /** End time in output-rate ms. */
61
+ endTimeMs: number
62
+ /** Duration in ms (`endTimeMs - startTimeMs`). */
63
+ durationMs: number
64
+ /** First sample index in the output timeline. */
65
+ startSample: number
66
+ /** Sample count in `samples` (interleaved if channels > 1). */
67
+ sampleCount: number
68
+ /** Output sample rate. */
69
+ sampleRate: number
70
+ /** Output channel count. */
71
+ channels: number
72
+ /** Interleaved Float32 samples in [-1, 1]. */
73
+ samples: Float32Array
74
+ /** True for the last chunk of a non-cancelled run. */
75
+ isFinal: boolean
76
+ }
77
+
78
+ export interface StreamAudioDataProgress {
79
+ requestId: string
80
+ processedMs: number
81
+ durationMs: number
82
+ progress: number
83
+ emittedChunks: number
84
+ bufferedChunks?: number
85
+ }
86
+
87
+ export interface StreamAudioDataResult {
88
+ requestId: string
89
+ durationMs: number
90
+ sampleRate: number
91
+ channels: number
92
+ chunks: number
93
+ samples: number
94
+ cancelled: boolean
95
+ }
96
+
97
+ export interface StreamAudioDataCallbacks {
98
+ /**
99
+ * Called with each decoded chunk. If this returns a Promise, native decode
100
+ * pauses until it resolves (backpressure). Throwing aborts the stream with
101
+ * `ERR_AUDIO_STREAM_DECODE_FAILED`.
102
+ */
103
+ onChunk: (chunk: StreamAudioDataChunk) => void | Promise<void>
104
+ /** Called whenever native reports progress. */
105
+ onProgress?: (progress: StreamAudioDataProgress) => void
106
+ }
107
+
108
+ export interface AudioDecodeCapabilities {
109
+ platform: 'ios' | 'android' | 'web'
110
+ supportedInputFormats: string[]
111
+ supportedOutputFormats: Array<'float32'>
112
+ supportsCancellation: boolean
113
+ supportsBackpressure: boolean
114
+ supportsTimeRange: boolean
115
+ supportsTargetSampleRate: boolean
116
+ supportsChannelMixing: boolean
117
+ knownLimitations?: string[]
118
+ }
119
+
120
+ const CHUNK_EVENT = 'AudioDataStreamChunk'
121
+ const PROGRESS_EVENT = 'AudioDataStreamProgress'
122
+ const COMPLETE_EVENT = 'AudioDataStreamComplete'
123
+ const ERROR_EVENT = 'AudioDataStreamError'
124
+
125
+ let cachedEmitter: LegacyEventEmitter | null = null
126
+ function getEmitter(): LegacyEventEmitter {
127
+ if (!cachedEmitter) {
128
+ cachedEmitter = new LegacyEventEmitter(AudioStudioModule)
129
+ }
130
+ return cachedEmitter
131
+ }
132
+
133
+ function generateRequestId(): string {
134
+ const g = globalThis as { crypto?: { randomUUID?: () => string } }
135
+ if (typeof g.crypto?.randomUUID === 'function') {
136
+ try {
137
+ return g.crypto.randomUUID()
138
+ } catch {
139
+ // fall through
140
+ }
141
+ }
142
+ return `asd_${Date.now().toString(36)}_${Math.random()
143
+ .toString(36)
144
+ .slice(2, 10)}`
145
+ }
146
+
147
+ function toFloat32(samples: unknown): Float32Array {
148
+ if (samples instanceof Float32Array) return samples
149
+ if (Array.isArray(samples)) {
150
+ const out = new Float32Array(samples.length)
151
+ for (let i = 0; i < samples.length; i++) {
152
+ const v = Number(samples[i])
153
+ out[i] = Number.isFinite(v) ? v : 0
154
+ }
155
+ return out
156
+ }
157
+ if (typeof samples === 'string') {
158
+ const bytes = base64ToBytes(samples)
159
+ const floatLength = Math.floor(bytes.byteLength / 4)
160
+ if (bytes.byteOffset % 4 === 0) {
161
+ return new Float32Array(bytes.buffer, bytes.byteOffset, floatLength)
162
+ }
163
+ const sliced = bytes.buffer.slice(
164
+ bytes.byteOffset,
165
+ bytes.byteOffset + bytes.byteLength
166
+ )
167
+ return new Float32Array(sliced, 0, Math.floor(sliced.byteLength / 4))
168
+ }
169
+ if (samples && typeof samples === 'object' && 'length' in samples) {
170
+ // ArrayLike fallback
171
+ const arr = samples as ArrayLike<number>
172
+ const out = new Float32Array(arr.length)
173
+ for (let i = 0; i < arr.length; i++) {
174
+ const v = Number(arr[i])
175
+ out[i] = Number.isFinite(v) ? v : 0
176
+ }
177
+ return out
178
+ }
179
+ return new Float32Array(0)
180
+ }
181
+
182
+ function base64ToBytes(input: string): Uint8Array {
183
+ const g = globalThis as { atob?: (s: string) => string }
184
+ if (typeof g.atob !== 'function') {
185
+ // Buffer path for environments without atob; React Native has atob.
186
+ const Buf = (
187
+ globalThis as {
188
+ Buffer?: {
189
+ from: (input: string, encoding: string) => Uint8Array
190
+ }
191
+ }
192
+ ).Buffer
193
+ if (Buf) return new Uint8Array(Buf.from(input, 'base64'))
194
+ return new Uint8Array(0)
195
+ }
196
+ const bin = g.atob(input)
197
+ const out = new Uint8Array(bin.length)
198
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
199
+ return out
200
+ }
201
+
202
+ function rejectInvalidRange(message: string): never {
203
+ throw new AudioStreamError({
204
+ code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
205
+ message,
206
+ recoverable: false,
207
+ })
208
+ }
209
+
210
+ function assertPositiveFiniteOption(
211
+ value: number | undefined,
212
+ name: string,
213
+ integer = false
214
+ ): void {
215
+ if (value === undefined) return
216
+ if (
217
+ !Number.isFinite(value) ||
218
+ value <= 0 ||
219
+ (integer && !Number.isInteger(value))
220
+ ) {
221
+ rejectInvalidRange(
222
+ `${name} must be a positive${integer ? ' integer' : ''}`
223
+ )
224
+ }
225
+ }
226
+
227
+ function validateOptions(options: StreamAudioDataOptions): void {
228
+ if (!options.fileUri) {
229
+ rejectInvalidRange('fileUri is required')
230
+ }
231
+ if (
232
+ options.startTimeMs !== undefined &&
233
+ options.endTimeMs !== undefined &&
234
+ options.startTimeMs >= options.endTimeMs
235
+ ) {
236
+ rejectInvalidRange('startTimeMs must be < endTimeMs')
237
+ }
238
+ if (options.endTimeMs !== undefined && options.endTimeMs <= 0) {
239
+ rejectInvalidRange('endTimeMs must be > 0')
240
+ }
241
+ if (options.startTimeMs !== undefined && options.startTimeMs < 0) {
242
+ rejectInvalidRange('startTimeMs must be >= 0')
243
+ }
244
+ if (
245
+ options.chunkDurationMs !== undefined &&
246
+ (options.chunkDurationMs < 10 || options.chunkDurationMs > 60000)
247
+ ) {
248
+ rejectInvalidRange('chunkDurationMs must be in [10, 60000]')
249
+ }
250
+ if (
251
+ options.backpressureTimeoutMs !== undefined &&
252
+ options.backpressureTimeoutMs < 0
253
+ ) {
254
+ rejectInvalidRange('backpressureTimeoutMs must be >= 0')
255
+ }
256
+ assertPositiveFiniteOption(options.targetSampleRate, 'targetSampleRate')
257
+ assertPositiveFiniteOption(options.sampleRate, 'sampleRate')
258
+ assertPositiveFiniteOption(options.channels, 'channels', true)
259
+ assertPositiveFiniteOption(
260
+ options.maxBufferedChunks,
261
+ 'maxBufferedChunks',
262
+ true
263
+ )
264
+ assertPositiveFiniteOption(options.maxChunkBytes, 'maxChunkBytes', true)
265
+
266
+ if (
267
+ options.streamFormat !== undefined &&
268
+ options.streamFormat !== 'float32'
269
+ ) {
270
+ throw new AudioStreamError({
271
+ code: 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT',
272
+ message: `Unsupported streamFormat: ${options.streamFormat}`,
273
+ recoverable: false,
274
+ })
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Stream decoded audio from a stored file as bounded Float32 PCM chunks.
280
+ *
281
+ * Memory bound:
282
+ * `chunkDurationMs * sampleRate * channels * 4 * maxBufferedChunks` +
283
+ * native decoder buffers.
284
+ *
285
+ * Cancellation: pass `options.signal` and call `abort()`. The returned promise
286
+ * resolves with `cancelled: true` (it does not reject) when cancellation wins.
287
+ *
288
+ * Backpressure: if `onChunk` returns a Promise, native decode is paused until
289
+ * it resolves; if it throws, the stream is aborted with a `decode_failed` error.
290
+ */
291
+ export async function streamAudioData(
292
+ options: StreamAudioDataOptions,
293
+ callbacks: StreamAudioDataCallbacks
294
+ ): Promise<StreamAudioDataResult> {
295
+ validateOptions(options)
296
+
297
+ if (isWeb) {
298
+ return streamAudioDataWeb(options, callbacks)
299
+ }
300
+
301
+ return streamAudioDataNative(options, callbacks)
302
+ }
303
+
304
+ /** Discover what the running platform supports. */
305
+ export async function getAudioDecodeCapabilities(): Promise<AudioDecodeCapabilities> {
306
+ if (isWeb) {
307
+ return {
308
+ platform: 'web',
309
+ supportedInputFormats: [
310
+ 'audio/wav',
311
+ 'audio/mpeg',
312
+ 'audio/mp4',
313
+ 'audio/aac',
314
+ 'audio/ogg',
315
+ 'audio/webm',
316
+ ],
317
+ supportedOutputFormats: ['float32'],
318
+ supportsCancellation: true,
319
+ supportsBackpressure: true,
320
+ supportsTimeRange: true,
321
+ supportsTargetSampleRate: true,
322
+ supportsChannelMixing: true,
323
+ knownLimitations: [
324
+ 'Web decodes the entire file via AudioContext.decodeAudioData before chunking; very long files may exceed browser memory.',
325
+ ],
326
+ }
327
+ }
328
+ if (typeof AudioStudioModule.getAudioDecodeCapabilities === 'function') {
329
+ try {
330
+ const caps = await AudioStudioModule.getAudioDecodeCapabilities()
331
+ return caps as AudioDecodeCapabilities
332
+ } catch (err) {
333
+ throw mapStreamError(err, undefined, 'native')
334
+ }
335
+ }
336
+ throw new AudioStreamError({
337
+ code: 'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE',
338
+ message: 'getAudioDecodeCapabilities is not available on this build',
339
+ recoverable: false,
340
+ })
341
+ }
342
+
343
+ async function streamAudioDataNative(
344
+ options: StreamAudioDataOptions,
345
+ callbacks: StreamAudioDataCallbacks
346
+ ): Promise<StreamAudioDataResult> {
347
+ if (typeof AudioStudioModule.streamAudioData !== 'function') {
348
+ throw new AudioStreamError({
349
+ code: 'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE',
350
+ message:
351
+ 'streamAudioData native method missing — rebuild the host app with the latest @siteed/audio-studio.',
352
+ recoverable: false,
353
+ })
354
+ }
355
+
356
+ const requestId = generateRequestId()
357
+ const emitter = getEmitter()
358
+ const subs: EventSubscription[] = []
359
+ let chunkCount = 0
360
+ let sampleCount = 0
361
+ let processingChain: Promise<void> = Promise.resolve()
362
+ let settled = false
363
+ let abortListener: (() => void) | null = null
364
+ let lastProgress: StreamAudioDataProgress | null = null
365
+
366
+ const finalize = () => {
367
+ for (const sub of subs) {
368
+ try {
369
+ sub.remove()
370
+ } catch {
371
+ /* noop */
372
+ }
373
+ }
374
+ subs.length = 0
375
+ if (abortListener && options.signal) {
376
+ options.signal.removeEventListener('abort', abortListener)
377
+ }
378
+ }
379
+
380
+ return new Promise<StreamAudioDataResult>((resolve, reject) => {
381
+ const settle = (
382
+ fn: () => void,
383
+ mode: 'resolve' | 'reject',
384
+ value: unknown
385
+ ) => {
386
+ if (settled) return
387
+ settled = true
388
+ finalize()
389
+ fn()
390
+ if (mode === 'resolve') {
391
+ resolve(value as StreamAudioDataResult)
392
+ } else {
393
+ reject(value as Error)
394
+ }
395
+ }
396
+
397
+ const handleError = (err: unknown) => {
398
+ settle(
399
+ () => {
400
+ try {
401
+ AudioStudioModule.cancelStreamAudioData?.(requestId)
402
+ } catch {
403
+ /* noop */
404
+ }
405
+ },
406
+ 'reject',
407
+ mapStreamError(err, options.fileUri, 'native')
408
+ )
409
+ }
410
+
411
+ subs.push(
412
+ emitter.addListener(CHUNK_EVENT, (raw: unknown) => {
413
+ const evt = raw as {
414
+ requestId: string
415
+ chunkIndex: number
416
+ startTimeMs: number
417
+ endTimeMs: number
418
+ startSample: number
419
+ sampleCount: number
420
+ sampleRate: number
421
+ channels: number
422
+ samples: Float32Array | number[] | string
423
+ isFinal: boolean
424
+ }
425
+ if (evt.requestId !== requestId) return
426
+ const chunk: StreamAudioDataChunk = {
427
+ requestId: evt.requestId,
428
+ chunkIndex: evt.chunkIndex,
429
+ startTimeMs: evt.startTimeMs,
430
+ endTimeMs: evt.endTimeMs,
431
+ durationMs: evt.endTimeMs - evt.startTimeMs,
432
+ startSample: evt.startSample,
433
+ sampleCount: evt.sampleCount,
434
+ sampleRate: evt.sampleRate,
435
+ channels: evt.channels,
436
+ samples: toFloat32(evt.samples),
437
+ isFinal: evt.isFinal,
438
+ }
439
+ chunkCount += 1
440
+ sampleCount += chunk.sampleCount
441
+
442
+ processingChain = processingChain
443
+ .then(async () => {
444
+ await callbacks.onChunk(chunk)
445
+ try {
446
+ AudioStudioModule.acknowledgeStreamAudioChunk?.(
447
+ requestId,
448
+ chunk.chunkIndex
449
+ )
450
+ } catch {
451
+ /* noop */
452
+ }
453
+ })
454
+ .catch(handleError)
455
+ })
456
+ )
457
+
458
+ if (callbacks.onProgress) {
459
+ subs.push(
460
+ emitter.addListener(PROGRESS_EVENT, (raw: unknown) => {
461
+ const evt = raw as StreamAudioDataProgress
462
+ if (evt.requestId !== requestId) return
463
+ lastProgress = evt
464
+ callbacks.onProgress!(evt)
465
+ })
466
+ )
467
+ }
468
+
469
+ subs.push(
470
+ emitter.addListener(COMPLETE_EVENT, (raw: unknown) => {
471
+ const evt = raw as {
472
+ requestId: string
473
+ durationMs: number
474
+ sampleRate: number
475
+ channels: number
476
+ chunks?: number
477
+ samples?: number
478
+ cancelled: boolean
479
+ }
480
+ if (evt.requestId !== requestId) return
481
+ // Wait for in-flight onChunk callbacks before resolving.
482
+ processingChain
483
+ .then(() => {
484
+ settle(() => {}, 'resolve', {
485
+ requestId,
486
+ durationMs:
487
+ evt.durationMs > 0
488
+ ? evt.durationMs
489
+ : (lastProgress?.durationMs ?? 0),
490
+ sampleRate: evt.sampleRate,
491
+ channels: evt.channels,
492
+ chunks: evt.chunks ?? chunkCount,
493
+ samples: evt.samples ?? sampleCount,
494
+ cancelled: evt.cancelled,
495
+ } satisfies StreamAudioDataResult)
496
+ })
497
+ .catch(handleError)
498
+ })
499
+ )
500
+
501
+ subs.push(
502
+ emitter.addListener(ERROR_EVENT, (raw: unknown) => {
503
+ const evt = raw as {
504
+ requestId: string
505
+ code?: string
506
+ message?: string
507
+ nativeMessage?: string
508
+ }
509
+ if (evt.requestId !== requestId) return
510
+ if (evt.code === 'ERR_AUDIO_STREAM_CANCELLED') {
511
+ processingChain
512
+ .catch(() => {})
513
+ .then(() => {
514
+ settle(() => {}, 'resolve', {
515
+ requestId,
516
+ durationMs: lastProgress?.durationMs ?? 0,
517
+ sampleRate:
518
+ options.targetSampleRate ??
519
+ options.sampleRate ??
520
+ 0,
521
+ channels: options.channels ?? 1,
522
+ chunks: chunkCount,
523
+ samples: sampleCount,
524
+ cancelled: true,
525
+ } satisfies StreamAudioDataResult)
526
+ })
527
+ return
528
+ }
529
+ handleError(
530
+ new AudioStreamError({
531
+ code:
532
+ (evt.code as AudioStreamErrorCode) ??
533
+ 'ERR_AUDIO_STREAM_UNKNOWN',
534
+ message: evt.message ?? 'native stream error',
535
+ nativeMessage: evt.nativeMessage,
536
+ fileUri: options.fileUri,
537
+ platform: 'native',
538
+ recoverable: false,
539
+ })
540
+ )
541
+ })
542
+ )
543
+
544
+ if (options.signal) {
545
+ if (options.signal.aborted) {
546
+ settle(() => {}, 'resolve', {
547
+ requestId,
548
+ durationMs: lastProgress?.durationMs ?? 0,
549
+ sampleRate:
550
+ options.targetSampleRate ?? options.sampleRate ?? 0,
551
+ channels: options.channels ?? 1,
552
+ chunks: 0,
553
+ samples: 0,
554
+ cancelled: true,
555
+ } satisfies StreamAudioDataResult)
556
+ return
557
+ }
558
+ abortListener = () => {
559
+ try {
560
+ AudioStudioModule.cancelStreamAudioData?.(requestId)
561
+ } catch {
562
+ /* noop */
563
+ }
564
+ }
565
+ options.signal.addEventListener('abort', abortListener)
566
+ }
567
+
568
+ const { signal: _signal, ...nativeOptions } = options
569
+ AudioStudioModule.streamAudioData({
570
+ ...nativeOptions,
571
+ requestId,
572
+ streamFormat: options.streamFormat ?? 'float32',
573
+ chunkDurationMs: options.chunkDurationMs ?? 1000,
574
+ maxBufferedChunks: options.maxBufferedChunks ?? 4,
575
+ normalizeAudio: options.normalizeAudio ?? true,
576
+ }).catch(handleError)
577
+ })
578
+ }
579
+
580
+ async function streamAudioDataWeb(
581
+ options: StreamAudioDataOptions,
582
+ callbacks: StreamAudioDataCallbacks
583
+ ): Promise<StreamAudioDataResult> {
584
+ const requestId = generateRequestId()
585
+ const cancelled = () => options.signal?.aborted === true
586
+ if (cancelled()) {
587
+ return {
588
+ requestId,
589
+ durationMs: 0,
590
+ sampleRate: options.targetSampleRate ?? options.sampleRate ?? 0,
591
+ channels: options.channels ?? 1,
592
+ chunks: 0,
593
+ samples: 0,
594
+ cancelled: true,
595
+ }
596
+ }
597
+
598
+ try {
599
+ const processed = await processAudioBuffer({
600
+ fileUri: options.fileUri,
601
+ targetSampleRate: options.targetSampleRate,
602
+ targetChannels: options.channels,
603
+ normalizeAudio: options.normalizeAudio ?? true,
604
+ startTimeMs: options.startTimeMs,
605
+ endTimeMs: options.endTimeMs,
606
+ })
607
+
608
+ const sampleRate = processed.sampleRate
609
+ const channels = processed.channels
610
+ const durationMs = processed.durationMs
611
+ const chunkDurationMs = options.chunkDurationMs ?? 1000
612
+ let samplesPerChunk = Math.max(
613
+ channels,
614
+ Math.floor((chunkDurationMs / 1000) * sampleRate) * channels
615
+ )
616
+ if (options.maxChunkBytes) {
617
+ // Round down to a multiple of `channels` so we never split an
618
+ // interleaved frame across two chunks (that would produce a
619
+ // fractional `startSample` for the next chunk).
620
+ const rawMax = Math.floor(options.maxChunkBytes / 4)
621
+ const maxSamples = Math.max(
622
+ channels,
623
+ Math.floor(rawMax / channels) * channels
624
+ )
625
+ samplesPerChunk = Math.max(
626
+ channels,
627
+ Math.min(samplesPerChunk, maxSamples)
628
+ )
629
+ }
630
+
631
+ const all = sanitizeFloat32(
632
+ interleaveBuffer(processed.buffer, channels),
633
+ options.normalizeAudio ?? true
634
+ )
635
+
636
+ // Chunk timestamps are absolute (range start + offset) on every
637
+ // platform; progress is *elapsed within the range* so the
638
+ // `processedMs / durationMs` fraction stays in [0, 1] regardless of
639
+ // `startTimeMs`. The native decoders use the same split.
640
+ const rangeStartMs = options.startTimeMs ?? 0
641
+ let chunkIndex = 0
642
+ let emittedSamples = 0
643
+ for (let off = 0; off < all.length; off += samplesPerChunk) {
644
+ if (cancelled()) {
645
+ return {
646
+ requestId,
647
+ durationMs,
648
+ sampleRate,
649
+ channels,
650
+ chunks: chunkIndex,
651
+ samples: emittedSamples,
652
+ cancelled: true,
653
+ }
654
+ }
655
+ const end = Math.min(off + samplesPerChunk, all.length)
656
+ const slice = all.slice(off, end)
657
+ const startSample = off / channels
658
+ const endSample = end / channels
659
+ const startMs =
660
+ Math.round((startSample / sampleRate) * 1000) + rangeStartMs
661
+ const endMs =
662
+ Math.round((endSample / sampleRate) * 1000) + rangeStartMs
663
+ const chunk: StreamAudioDataChunk = {
664
+ requestId,
665
+ chunkIndex,
666
+ startTimeMs: startMs,
667
+ endTimeMs: endMs,
668
+ durationMs: Math.round(
669
+ ((endSample - startSample) / sampleRate) * 1000
670
+ ),
671
+ startSample,
672
+ sampleCount: slice.length,
673
+ sampleRate,
674
+ channels,
675
+ samples: slice,
676
+ isFinal: end >= all.length,
677
+ }
678
+ await callbacks.onChunk(chunk)
679
+ // Resample rounding (Math.ceil in processAudioBuffer) can push
680
+ // elapsed past the source-rate-derived range duration on the tail
681
+ // chunk. Cap so onProgress consumers always see a [0, 1] ratio,
682
+ // matching the native `coerceIn(0, 1)` / `min(1, max(0, …))`
683
+ // clamp.
684
+ const rawElapsedMs = Math.round((endSample / sampleRate) * 1000)
685
+ const elapsedMs =
686
+ durationMs > 0
687
+ ? Math.min(rawElapsedMs, durationMs)
688
+ : rawElapsedMs
689
+ callbacks.onProgress?.({
690
+ requestId,
691
+ processedMs: elapsedMs,
692
+ durationMs,
693
+ progress:
694
+ durationMs > 0
695
+ ? Math.min(1, Math.max(0, elapsedMs / durationMs))
696
+ : 1,
697
+ emittedChunks: chunkIndex + 1,
698
+ })
699
+ chunkIndex += 1
700
+ emittedSamples += slice.length
701
+ }
702
+
703
+ return {
704
+ requestId,
705
+ durationMs,
706
+ sampleRate,
707
+ channels,
708
+ chunks: chunkIndex,
709
+ samples: emittedSamples,
710
+ cancelled: false,
711
+ }
712
+ } catch (err) {
713
+ throw mapStreamError(err, options.fileUri, 'web')
714
+ }
715
+ }
716
+
717
+ function interleaveBuffer(buffer: AudioBuffer, channels: number): Float32Array {
718
+ const numCh = Math.max(1, Math.min(channels, buffer.numberOfChannels))
719
+ const framesPerCh = buffer.length
720
+ if (numCh === 1) {
721
+ // Cheap path: clone channel 0 so downstream mutation doesn't touch the
722
+ // underlying AudioBuffer storage.
723
+ return new Float32Array(buffer.getChannelData(0))
724
+ }
725
+ const out = new Float32Array(framesPerCh * numCh)
726
+ const channelData: Float32Array[] = []
727
+ for (let c = 0; c < numCh; c++) {
728
+ channelData.push(buffer.getChannelData(c))
729
+ }
730
+ for (let f = 0; f < framesPerCh; f++) {
731
+ for (let c = 0; c < numCh; c++) {
732
+ out[f * numCh + c] = channelData[c][f]
733
+ }
734
+ }
735
+ return out
736
+ }
737
+
738
+ function sanitizeFloat32(input: Float32Array, clamp: boolean): Float32Array {
739
+ if (!clamp) {
740
+ // still need NaN/Inf sanitation
741
+ for (let i = 0; i < input.length; i++) {
742
+ const v = input[i]
743
+ if (!Number.isFinite(v)) input[i] = 0
744
+ }
745
+ return input
746
+ }
747
+ for (let i = 0; i < input.length; i++) {
748
+ const v = input[i]
749
+ if (!Number.isFinite(v)) {
750
+ input[i] = 0
751
+ } else if (v > 1) {
752
+ input[i] = 1
753
+ } else if (v < -1) {
754
+ input[i] = -1
755
+ }
756
+ }
757
+ return input
758
+ }