@siteed/audio-studio 3.1.0 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +97 -50
  3. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
  4. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
  5. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
  6. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
  7. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
  8. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
  9. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +187 -13
  10. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  11. package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
  12. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  13. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  14. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  15. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  16. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  17. package/build/cjs/errors/AudioExtractionError.js +127 -0
  18. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  19. package/build/cjs/errors/AudioStreamError.js +152 -0
  20. package/build/cjs/errors/AudioStreamError.js.map +1 -0
  21. package/build/cjs/errors/AudioStreamError.test.js +61 -0
  22. package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
  23. package/build/cjs/index.js +12 -1
  24. package/build/cjs/index.js.map +1 -1
  25. package/build/cjs/streamAudioData.js +467 -0
  26. package/build/cjs/streamAudioData.js.map +1 -0
  27. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  28. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  29. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  30. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  31. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  32. package/build/esm/errors/AudioExtractionError.js +122 -0
  33. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  34. package/build/esm/errors/AudioStreamError.js +147 -0
  35. package/build/esm/errors/AudioStreamError.js.map +1 -0
  36. package/build/esm/errors/AudioStreamError.test.js +59 -0
  37. package/build/esm/errors/AudioStreamError.test.js.map +1 -0
  38. package/build/esm/index.js +5 -1
  39. package/build/esm/index.js.map +1 -1
  40. package/build/esm/streamAudioData.js +460 -0
  41. package/build/esm/streamAudioData.js.map +1 -0
  42. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  43. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  44. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  45. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  46. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  47. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  48. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  49. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  50. package/build/types/errors/AudioStreamError.d.ts +25 -0
  51. package/build/types/errors/AudioStreamError.d.ts.map +1 -0
  52. package/build/types/errors/AudioStreamError.test.d.ts +2 -0
  53. package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
  54. package/build/types/index.d.ts +8 -1
  55. package/build/types/index.d.ts.map +1 -1
  56. package/build/types/streamAudioData.d.ts +114 -0
  57. package/build/types/streamAudioData.d.ts.map +1 -0
  58. package/ios/AudioProcessingHelpers.swift +10 -5
  59. package/ios/AudioProcessor.swift +99 -0
  60. package/ios/AudioStreamDecoder.swift +523 -0
  61. package/ios/AudioStudioModule.swift +210 -3
  62. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
  63. package/package.json +7 -7
  64. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  65. package/src/AudioAnalysis/extractPreview.ts +118 -17
  66. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  67. package/src/errors/AudioExtractionError.ts +167 -0
  68. package/src/errors/AudioStreamError.test.ts +65 -0
  69. package/src/errors/AudioStreamError.ts +185 -0
  70. package/src/index.ts +34 -0
  71. package/src/streamAudioData.ts +654 -0
@@ -0,0 +1,654 @@
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
+ /** Output PCM format; only `'float32'` supported today. */
43
+ streamFormat?: 'float32'
44
+ /** Abort the in-flight request. Resolves promise with `cancelled: true`. */
45
+ signal?: AbortSignal
46
+ }
47
+
48
+ export interface StreamAudioDataChunk {
49
+ /** Native request id; constant across all chunks of one call. */
50
+ requestId: string
51
+ /** Zero-based monotonic chunk index. */
52
+ chunkIndex: number
53
+ /** Start time in output-rate ms (rounded to nearest sample). */
54
+ startTimeMs: number
55
+ /** End time in output-rate ms. */
56
+ endTimeMs: number
57
+ /** Duration in ms (`endTimeMs - startTimeMs`). */
58
+ durationMs: number
59
+ /** First sample index in the output timeline. */
60
+ startSample: number
61
+ /** Sample count in `samples` (interleaved if channels > 1). */
62
+ sampleCount: number
63
+ /** Output sample rate. */
64
+ sampleRate: number
65
+ /** Output channel count. */
66
+ channels: number
67
+ /** Interleaved Float32 samples in [-1, 1]. */
68
+ samples: Float32Array
69
+ /** True for the last chunk of a non-cancelled run. */
70
+ isFinal: boolean
71
+ }
72
+
73
+ export interface StreamAudioDataProgress {
74
+ requestId: string
75
+ processedMs: number
76
+ durationMs: number
77
+ progress: number
78
+ emittedChunks: number
79
+ bufferedChunks?: number
80
+ }
81
+
82
+ export interface StreamAudioDataResult {
83
+ requestId: string
84
+ durationMs: number
85
+ sampleRate: number
86
+ channels: number
87
+ chunks: number
88
+ samples: number
89
+ cancelled: boolean
90
+ }
91
+
92
+ export interface StreamAudioDataCallbacks {
93
+ /**
94
+ * Called with each decoded chunk. If this returns a Promise, native decode
95
+ * pauses until it resolves (backpressure). Throwing aborts the stream with
96
+ * `ERR_AUDIO_STREAM_DECODE_FAILED`.
97
+ */
98
+ onChunk: (chunk: StreamAudioDataChunk) => void | Promise<void>
99
+ /** Called whenever native reports progress. */
100
+ onProgress?: (progress: StreamAudioDataProgress) => void
101
+ }
102
+
103
+ export interface AudioDecodeCapabilities {
104
+ platform: 'ios' | 'android' | 'web'
105
+ supportedInputFormats: string[]
106
+ supportedOutputFormats: Array<'float32'>
107
+ supportsCancellation: boolean
108
+ supportsBackpressure: boolean
109
+ supportsTimeRange: boolean
110
+ supportsTargetSampleRate: boolean
111
+ supportsChannelMixing: boolean
112
+ knownLimitations?: string[]
113
+ }
114
+
115
+ const CHUNK_EVENT = 'AudioDataStreamChunk'
116
+ const PROGRESS_EVENT = 'AudioDataStreamProgress'
117
+ const COMPLETE_EVENT = 'AudioDataStreamComplete'
118
+ const ERROR_EVENT = 'AudioDataStreamError'
119
+
120
+ let cachedEmitter: LegacyEventEmitter | null = null
121
+ function getEmitter(): LegacyEventEmitter {
122
+ if (!cachedEmitter) {
123
+ cachedEmitter = new LegacyEventEmitter(AudioStudioModule)
124
+ }
125
+ return cachedEmitter
126
+ }
127
+
128
+ function generateRequestId(): string {
129
+ const g = globalThis as { crypto?: { randomUUID?: () => string } }
130
+ if (typeof g.crypto?.randomUUID === 'function') {
131
+ try {
132
+ return g.crypto.randomUUID()
133
+ } catch {
134
+ // fall through
135
+ }
136
+ }
137
+ return `asd_${Date.now().toString(36)}_${Math.random()
138
+ .toString(36)
139
+ .slice(2, 10)}`
140
+ }
141
+
142
+ function toFloat32(samples: unknown): Float32Array {
143
+ if (samples instanceof Float32Array) return samples
144
+ if (Array.isArray(samples)) {
145
+ const out = new Float32Array(samples.length)
146
+ for (let i = 0; i < samples.length; i++) {
147
+ const v = Number(samples[i])
148
+ out[i] = Number.isFinite(v) ? v : 0
149
+ }
150
+ return out
151
+ }
152
+ if (typeof samples === 'string') {
153
+ const bytes = base64ToBytes(samples)
154
+ const aligned =
155
+ bytes.byteOffset % 4 === 0
156
+ ? new Float32Array(
157
+ bytes.buffer,
158
+ bytes.byteOffset,
159
+ bytes.byteLength / 4
160
+ )
161
+ : new Float32Array(bytes.buffer.slice(bytes.byteOffset))
162
+ return new Float32Array(aligned)
163
+ }
164
+ if (samples && typeof samples === 'object' && 'length' in samples) {
165
+ // ArrayLike fallback
166
+ const arr = samples as ArrayLike<number>
167
+ const out = new Float32Array(arr.length)
168
+ for (let i = 0; i < arr.length; i++) {
169
+ const v = Number(arr[i])
170
+ out[i] = Number.isFinite(v) ? v : 0
171
+ }
172
+ return out
173
+ }
174
+ return new Float32Array(0)
175
+ }
176
+
177
+ function base64ToBytes(input: string): Uint8Array {
178
+ const g = globalThis as { atob?: (s: string) => string }
179
+ if (typeof g.atob !== 'function') {
180
+ // Buffer path for environments without atob; React Native has atob.
181
+ const Buf = (globalThis as { Buffer?: { from: Function } }).Buffer
182
+ if (Buf) return new Uint8Array(Buf.from(input, 'base64'))
183
+ return new Uint8Array(0)
184
+ }
185
+ const bin = g.atob(input)
186
+ const out = new Uint8Array(bin.length)
187
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
188
+ return out
189
+ }
190
+
191
+ function validateOptions(options: StreamAudioDataOptions): void {
192
+ if (!options.fileUri) {
193
+ throw new AudioStreamError({
194
+ code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
195
+ message: 'fileUri is required',
196
+ recoverable: false,
197
+ })
198
+ }
199
+ if (
200
+ options.startTimeMs !== undefined &&
201
+ options.endTimeMs !== undefined &&
202
+ options.startTimeMs >= options.endTimeMs
203
+ ) {
204
+ throw new AudioStreamError({
205
+ code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
206
+ message: 'startTimeMs must be < endTimeMs',
207
+ recoverable: false,
208
+ })
209
+ }
210
+ if (
211
+ options.chunkDurationMs !== undefined &&
212
+ (options.chunkDurationMs < 10 || options.chunkDurationMs > 60000)
213
+ ) {
214
+ throw new AudioStreamError({
215
+ code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
216
+ message: 'chunkDurationMs must be in [10, 60000]',
217
+ recoverable: false,
218
+ })
219
+ }
220
+ if (
221
+ options.streamFormat !== undefined &&
222
+ options.streamFormat !== 'float32'
223
+ ) {
224
+ throw new AudioStreamError({
225
+ code: 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT',
226
+ message: `Unsupported streamFormat: ${options.streamFormat}`,
227
+ recoverable: false,
228
+ })
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Stream decoded audio from a stored file as bounded Float32 PCM chunks.
234
+ *
235
+ * Memory bound:
236
+ * `chunkDurationMs * sampleRate * channels * 4 * maxBufferedChunks` +
237
+ * native decoder buffers.
238
+ *
239
+ * Cancellation: pass `options.signal` and call `abort()`. The returned promise
240
+ * resolves with `cancelled: true` (it does not reject) when cancellation wins.
241
+ *
242
+ * Backpressure: if `onChunk` returns a Promise, native decode is paused until
243
+ * it resolves; if it throws, the stream is aborted with a `decode_failed` error.
244
+ */
245
+ export async function streamAudioData(
246
+ options: StreamAudioDataOptions,
247
+ callbacks: StreamAudioDataCallbacks
248
+ ): Promise<StreamAudioDataResult> {
249
+ validateOptions(options)
250
+
251
+ if (isWeb) {
252
+ return streamAudioDataWeb(options, callbacks)
253
+ }
254
+
255
+ return streamAudioDataNative(options, callbacks)
256
+ }
257
+
258
+ /** Discover what the running platform supports. */
259
+ export async function getAudioDecodeCapabilities(): Promise<AudioDecodeCapabilities> {
260
+ if (isWeb) {
261
+ return {
262
+ platform: 'web',
263
+ supportedInputFormats: [
264
+ 'audio/wav',
265
+ 'audio/mpeg',
266
+ 'audio/mp4',
267
+ 'audio/aac',
268
+ 'audio/ogg',
269
+ 'audio/webm',
270
+ ],
271
+ supportedOutputFormats: ['float32'],
272
+ supportsCancellation: true,
273
+ supportsBackpressure: true,
274
+ supportsTimeRange: true,
275
+ supportsTargetSampleRate: true,
276
+ supportsChannelMixing: true,
277
+ knownLimitations: [
278
+ 'Web decodes the entire file via AudioContext.decodeAudioData before chunking; very long files may exceed browser memory.',
279
+ ],
280
+ }
281
+ }
282
+ if (typeof AudioStudioModule.getAudioDecodeCapabilities === 'function') {
283
+ try {
284
+ const caps = await AudioStudioModule.getAudioDecodeCapabilities()
285
+ return caps as AudioDecodeCapabilities
286
+ } catch (err) {
287
+ throw mapStreamError(err, undefined, 'native')
288
+ }
289
+ }
290
+ throw new AudioStreamError({
291
+ code: 'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE',
292
+ message: 'getAudioDecodeCapabilities is not available on this build',
293
+ recoverable: false,
294
+ })
295
+ }
296
+
297
+ async function streamAudioDataNative(
298
+ options: StreamAudioDataOptions,
299
+ callbacks: StreamAudioDataCallbacks
300
+ ): Promise<StreamAudioDataResult> {
301
+ if (typeof AudioStudioModule.streamAudioData !== 'function') {
302
+ throw new AudioStreamError({
303
+ code: 'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE',
304
+ message:
305
+ 'streamAudioData native method missing — rebuild the host app with the latest @siteed/audio-studio.',
306
+ recoverable: false,
307
+ })
308
+ }
309
+
310
+ const requestId = generateRequestId()
311
+ const emitter = getEmitter()
312
+ const subs: EventSubscription[] = []
313
+ let chunkCount = 0
314
+ let sampleCount = 0
315
+ let processingChain: Promise<void> = Promise.resolve()
316
+ let settled = false
317
+ let abortListener: (() => void) | null = null
318
+
319
+ const finalize = () => {
320
+ for (const sub of subs) {
321
+ try {
322
+ sub.remove()
323
+ } catch {
324
+ /* noop */
325
+ }
326
+ }
327
+ subs.length = 0
328
+ if (abortListener && options.signal) {
329
+ options.signal.removeEventListener('abort', abortListener)
330
+ }
331
+ }
332
+
333
+ return new Promise<StreamAudioDataResult>((resolve, reject) => {
334
+ const settle = (
335
+ fn: () => void,
336
+ mode: 'resolve' | 'reject',
337
+ value: unknown
338
+ ) => {
339
+ if (settled) return
340
+ settled = true
341
+ finalize()
342
+ fn()
343
+ if (mode === 'resolve') {
344
+ resolve(value as StreamAudioDataResult)
345
+ } else {
346
+ reject(value as Error)
347
+ }
348
+ }
349
+
350
+ const handleError = (err: unknown) => {
351
+ settle(
352
+ () => {
353
+ try {
354
+ AudioStudioModule.cancelStreamAudioData?.(requestId)
355
+ } catch {
356
+ /* noop */
357
+ }
358
+ },
359
+ 'reject',
360
+ mapStreamError(err, options.fileUri, 'native')
361
+ )
362
+ }
363
+
364
+ subs.push(
365
+ emitter.addListener(CHUNK_EVENT, (raw: unknown) => {
366
+ const evt = raw as {
367
+ requestId: string
368
+ chunkIndex: number
369
+ startTimeMs: number
370
+ endTimeMs: number
371
+ startSample: number
372
+ sampleCount: number
373
+ sampleRate: number
374
+ channels: number
375
+ samples: Float32Array | number[] | string
376
+ isFinal: boolean
377
+ }
378
+ if (evt.requestId !== requestId) return
379
+ const chunk: StreamAudioDataChunk = {
380
+ requestId: evt.requestId,
381
+ chunkIndex: evt.chunkIndex,
382
+ startTimeMs: evt.startTimeMs,
383
+ endTimeMs: evt.endTimeMs,
384
+ durationMs: evt.endTimeMs - evt.startTimeMs,
385
+ startSample: evt.startSample,
386
+ sampleCount: evt.sampleCount,
387
+ sampleRate: evt.sampleRate,
388
+ channels: evt.channels,
389
+ samples: toFloat32(evt.samples),
390
+ isFinal: evt.isFinal,
391
+ }
392
+ chunkCount += 1
393
+ sampleCount += chunk.sampleCount
394
+
395
+ processingChain = processingChain
396
+ .then(async () => {
397
+ await callbacks.onChunk(chunk)
398
+ try {
399
+ AudioStudioModule.acknowledgeStreamAudioChunk?.(
400
+ requestId,
401
+ chunk.chunkIndex
402
+ )
403
+ } catch {
404
+ /* noop */
405
+ }
406
+ })
407
+ .catch(handleError)
408
+ })
409
+ )
410
+
411
+ if (callbacks.onProgress) {
412
+ subs.push(
413
+ emitter.addListener(PROGRESS_EVENT, (raw: unknown) => {
414
+ const evt = raw as StreamAudioDataProgress
415
+ if (evt.requestId !== requestId) return
416
+ callbacks.onProgress!(evt)
417
+ })
418
+ )
419
+ }
420
+
421
+ subs.push(
422
+ emitter.addListener(COMPLETE_EVENT, (raw: unknown) => {
423
+ const evt = raw as {
424
+ requestId: string
425
+ durationMs: number
426
+ sampleRate: number
427
+ channels: number
428
+ chunks?: number
429
+ samples?: number
430
+ cancelled: boolean
431
+ }
432
+ if (evt.requestId !== requestId) return
433
+ // Wait for in-flight onChunk callbacks before resolving.
434
+ processingChain
435
+ .then(() => {
436
+ settle(() => {}, 'resolve', {
437
+ requestId,
438
+ durationMs: evt.durationMs,
439
+ sampleRate: evt.sampleRate,
440
+ channels: evt.channels,
441
+ chunks: evt.chunks ?? chunkCount,
442
+ samples: evt.samples ?? sampleCount,
443
+ cancelled: evt.cancelled,
444
+ } satisfies StreamAudioDataResult)
445
+ })
446
+ .catch(handleError)
447
+ })
448
+ )
449
+
450
+ subs.push(
451
+ emitter.addListener(ERROR_EVENT, (raw: unknown) => {
452
+ const evt = raw as {
453
+ requestId: string
454
+ code?: string
455
+ message?: string
456
+ nativeMessage?: string
457
+ }
458
+ if (evt.requestId !== requestId) return
459
+ if (evt.code === 'ERR_AUDIO_STREAM_CANCELLED') {
460
+ processingChain
461
+ .catch(() => {})
462
+ .then(() => {
463
+ settle(() => {}, 'resolve', {
464
+ requestId,
465
+ durationMs: 0,
466
+ sampleRate:
467
+ options.targetSampleRate ??
468
+ options.sampleRate ??
469
+ 0,
470
+ channels: options.channels ?? 1,
471
+ chunks: chunkCount,
472
+ samples: sampleCount,
473
+ cancelled: true,
474
+ } satisfies StreamAudioDataResult)
475
+ })
476
+ return
477
+ }
478
+ handleError(
479
+ new AudioStreamError({
480
+ code:
481
+ (evt.code as AudioStreamErrorCode) ??
482
+ 'ERR_AUDIO_STREAM_UNKNOWN',
483
+ message: evt.message ?? 'native stream error',
484
+ nativeMessage: evt.nativeMessage,
485
+ fileUri: options.fileUri,
486
+ platform: 'native',
487
+ recoverable: false,
488
+ })
489
+ )
490
+ })
491
+ )
492
+
493
+ if (options.signal) {
494
+ if (options.signal.aborted) {
495
+ settle(() => {}, 'resolve', {
496
+ requestId,
497
+ durationMs: 0,
498
+ sampleRate:
499
+ options.targetSampleRate ?? options.sampleRate ?? 0,
500
+ channels: options.channels ?? 1,
501
+ chunks: 0,
502
+ samples: 0,
503
+ cancelled: true,
504
+ } satisfies StreamAudioDataResult)
505
+ return
506
+ }
507
+ abortListener = () => {
508
+ try {
509
+ AudioStudioModule.cancelStreamAudioData?.(requestId)
510
+ } catch {
511
+ /* noop */
512
+ }
513
+ }
514
+ options.signal.addEventListener('abort', abortListener)
515
+ }
516
+
517
+ const { signal: _signal, ...nativeOptions } = options
518
+ AudioStudioModule.streamAudioData({
519
+ ...nativeOptions,
520
+ requestId,
521
+ streamFormat: options.streamFormat ?? 'float32',
522
+ chunkDurationMs: options.chunkDurationMs ?? 1000,
523
+ maxBufferedChunks: options.maxBufferedChunks ?? 4,
524
+ normalizeAudio: options.normalizeAudio ?? true,
525
+ }).catch(handleError)
526
+ })
527
+ }
528
+
529
+ async function streamAudioDataWeb(
530
+ options: StreamAudioDataOptions,
531
+ callbacks: StreamAudioDataCallbacks
532
+ ): Promise<StreamAudioDataResult> {
533
+ const requestId = generateRequestId()
534
+ const cancelled = () => options.signal?.aborted === true
535
+ if (cancelled()) {
536
+ return {
537
+ requestId,
538
+ durationMs: 0,
539
+ sampleRate: options.targetSampleRate ?? options.sampleRate ?? 0,
540
+ channels: options.channels ?? 1,
541
+ chunks: 0,
542
+ samples: 0,
543
+ cancelled: true,
544
+ }
545
+ }
546
+
547
+ try {
548
+ const processed = await processAudioBuffer({
549
+ fileUri: options.fileUri,
550
+ targetSampleRate: options.targetSampleRate ?? 16000,
551
+ targetChannels: options.channels ?? 1,
552
+ normalizeAudio: options.normalizeAudio ?? true,
553
+ startTimeMs: options.startTimeMs,
554
+ endTimeMs: options.endTimeMs,
555
+ })
556
+
557
+ const sampleRate = processed.sampleRate
558
+ const channels = processed.channels
559
+ const durationMs = processed.durationMs
560
+ const chunkDurationMs = options.chunkDurationMs ?? 1000
561
+ let samplesPerChunk = Math.max(
562
+ 1,
563
+ Math.floor((chunkDurationMs / 1000) * sampleRate) * channels
564
+ )
565
+ if (options.maxChunkBytes) {
566
+ const maxSamples = Math.floor(options.maxChunkBytes / 4)
567
+ samplesPerChunk = Math.min(samplesPerChunk, maxSamples)
568
+ }
569
+
570
+ const all = sanitizeFloat32(processed.channelData, options.normalizeAudio ?? true)
571
+
572
+ let chunkIndex = 0
573
+ let emittedSamples = 0
574
+ for (let off = 0; off < all.length; off += samplesPerChunk) {
575
+ if (cancelled()) {
576
+ return {
577
+ requestId,
578
+ durationMs,
579
+ sampleRate,
580
+ channels,
581
+ chunks: chunkIndex,
582
+ samples: emittedSamples,
583
+ cancelled: true,
584
+ }
585
+ }
586
+ const end = Math.min(off + samplesPerChunk, all.length)
587
+ const slice = all.slice(off, end)
588
+ const startSample = off / channels
589
+ const endSample = end / channels
590
+ const chunk: StreamAudioDataChunk = {
591
+ requestId,
592
+ chunkIndex,
593
+ startTimeMs: Math.round((startSample / sampleRate) * 1000),
594
+ endTimeMs: Math.round((endSample / sampleRate) * 1000),
595
+ durationMs: Math.round(
596
+ ((endSample - startSample) / sampleRate) * 1000
597
+ ),
598
+ startSample,
599
+ sampleCount: slice.length,
600
+ sampleRate,
601
+ channels,
602
+ samples: slice,
603
+ isFinal: end >= all.length,
604
+ }
605
+ await callbacks.onChunk(chunk)
606
+ callbacks.onProgress?.({
607
+ requestId,
608
+ processedMs: chunk.endTimeMs,
609
+ durationMs,
610
+ progress: durationMs > 0 ? chunk.endTimeMs / durationMs : 1,
611
+ emittedChunks: chunkIndex + 1,
612
+ })
613
+ chunkIndex += 1
614
+ emittedSamples += slice.length
615
+ }
616
+
617
+ return {
618
+ requestId,
619
+ durationMs,
620
+ sampleRate,
621
+ channels,
622
+ chunks: chunkIndex,
623
+ samples: emittedSamples,
624
+ cancelled: false,
625
+ }
626
+ } catch (err) {
627
+ throw mapStreamError(err, options.fileUri, 'web')
628
+ }
629
+ }
630
+
631
+ function sanitizeFloat32(
632
+ input: Float32Array,
633
+ clamp: boolean
634
+ ): Float32Array {
635
+ if (!clamp) {
636
+ // still need NaN/Inf sanitation
637
+ for (let i = 0; i < input.length; i++) {
638
+ const v = input[i]
639
+ if (!Number.isFinite(v)) input[i] = 0
640
+ }
641
+ return input
642
+ }
643
+ for (let i = 0; i < input.length; i++) {
644
+ const v = input[i]
645
+ if (!Number.isFinite(v)) {
646
+ input[i] = 0
647
+ } else if (v > 1) {
648
+ input[i] = 1
649
+ } else if (v < -1) {
650
+ input[i] = -1
651
+ }
652
+ }
653
+ return input
654
+ }