@siteed/audio-studio 3.2.0-beta.1 → 3.2.1-beta.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 (93) hide show
  1. package/CHANGELOG.md +356 -5
  2. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +12 -12
  3. package/android/src/main/java/net/siteed/audiostudio/AudioRecordingService.kt +1 -1
  4. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
  5. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +43 -10
  6. package/android/src/main/java/net/siteed/audiostudio/RecordingActionReceiver.kt +1 -1
  7. package/build/cjs/AudioRecorder.provider.js +3 -37
  8. package/build/cjs/AudioRecorder.provider.js.map +1 -1
  9. package/build/cjs/errors/AudioStreamError.js +9 -0
  10. package/build/cjs/errors/AudioStreamError.js.map +1 -1
  11. package/build/cjs/errors/AudioStreamError.test.js +22 -1
  12. package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
  13. package/build/cjs/streamAudioData.js +99 -32
  14. package/build/cjs/streamAudioData.js.map +1 -1
  15. package/build/cjs/utils/audioProcessing.js +14 -10
  16. package/build/cjs/utils/audioProcessing.js.map +1 -1
  17. package/build/esm/AudioRecorder.provider.js +3 -4
  18. package/build/esm/AudioRecorder.provider.js.map +1 -1
  19. package/build/esm/errors/AudioStreamError.js +9 -0
  20. package/build/esm/errors/AudioStreamError.js.map +1 -1
  21. package/build/esm/errors/AudioStreamError.test.js +22 -1
  22. package/build/esm/errors/AudioStreamError.test.js.map +1 -1
  23. package/build/esm/streamAudioData.js +99 -32
  24. package/build/esm/streamAudioData.js.map +1 -1
  25. package/build/esm/utils/audioProcessing.js +14 -10
  26. package/build/esm/utils/audioProcessing.js.map +1 -1
  27. package/build/types/errors/AudioStreamError.d.ts.map +1 -1
  28. package/build/types/streamAudioData.d.ts +5 -0
  29. package/build/types/streamAudioData.d.ts.map +1 -1
  30. package/build/types/utils/audioProcessing.d.ts +2 -2
  31. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  32. package/ios/AudioStreamDecoder.swift +191 -100
  33. package/ios/AudioStudio.podspec +1 -1
  34. package/ios/AudioStudioModule.swift +48 -9
  35. package/package.json +32 -15
  36. package/plugin/tsconfig.json +8 -2
  37. package/src/errors/AudioStreamError.test.ts +29 -2
  38. package/src/errors/AudioStreamError.ts +14 -0
  39. package/src/streamAudioData.ts +146 -42
  40. package/src/utils/audioProcessing.ts +25 -14
  41. package/android/src/androidTest/assets/chorus.wav +0 -0
  42. package/android/src/androidTest/assets/jfk.wav +0 -0
  43. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  44. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  45. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  46. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  47. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  48. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  49. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  50. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  51. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  52. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  53. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  54. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  55. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  56. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  57. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  58. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  59. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  60. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  61. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  62. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  63. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  64. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  65. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  66. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  67. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  68. package/android/src/test/resources/chorus.wav +0 -0
  69. package/android/src/test/resources/generate_test_audio.py +0 -94
  70. package/android/src/test/resources/jfk.wav +0 -0
  71. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  72. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  73. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  74. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  75. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
  76. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  77. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  78. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  79. package/ios/AudioStudioTests/Info.plist +0 -22
  80. package/ios/AudioStudioTests/README.md +0 -39
  81. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  82. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  83. package/ios/tests/README.md +0 -41
  84. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  85. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  86. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  87. package/ios/tests/integration/output_control_test.swift +0 -322
  88. package/ios/tests/integration/run_integration_tests.sh +0 -37
  89. package/ios/tests/opus_support_test_macos.swift +0 -154
  90. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  91. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  92. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  93. package/ios/tests/standalone/standalone_test.swift +0 -144
@@ -11,13 +11,18 @@ describe('AudioStreamError', () => {
11
11
  })
12
12
 
13
13
  it('maps native FILE_NOT_FOUND code', () => {
14
- const mapped = mapStreamError({ code: 'FILE_NOT_FOUND', message: 'gone' })
14
+ const mapped = mapStreamError({
15
+ code: 'FILE_NOT_FOUND',
16
+ message: 'gone',
17
+ })
15
18
  expect(mapped.code).toBe('ERR_AUDIO_STREAM_FILE_NOT_FOUND')
16
19
  expect(mapped.recoverable).toBe(false)
17
20
  })
18
21
 
19
22
  it('maps unsupported codec text', () => {
20
- const mapped = mapStreamError(new Error('No suitable codec for audio/opus'))
23
+ const mapped = mapStreamError(
24
+ new Error('No suitable codec for audio/opus')
25
+ )
21
26
  expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT')
22
27
  })
23
28
 
@@ -30,11 +35,33 @@ describe('AudioStreamError', () => {
30
35
  expect(mapped.recoverable).toBe(true)
31
36
  })
32
37
 
38
+ it('maps backpressure timeout as recoverable', () => {
39
+ const mapped = mapStreamError({
40
+ code: 'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT',
41
+ message: 'ack timed out',
42
+ })
43
+ expect(mapped.code).toBe('ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT')
44
+ expect(mapped.recoverable).toBe(true)
45
+ })
46
+
33
47
  it('falls back to UNKNOWN', () => {
34
48
  const mapped = mapStreamError({})
35
49
  expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNKNOWN')
36
50
  })
37
51
 
52
+ it('warns when native returns an unknown audio stream code', () => {
53
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
54
+ const mapped = mapStreamError({
55
+ code: 'ERR_AUDIO_STREAM_FOOBAR',
56
+ message: 'new native code',
57
+ })
58
+ expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNKNOWN')
59
+ expect(warn).toHaveBeenCalledWith(
60
+ '[AudioStreamError] Unknown native audio stream error code: ERR_AUDIO_STREAM_FOOBAR'
61
+ )
62
+ warn.mockRestore()
63
+ })
64
+
38
65
  it('preserves nativeCode and nativeMessage', () => {
39
66
  const mapped = mapStreamError({
40
67
  code: 'WEIRD_NATIVE_CODE',
@@ -80,6 +80,14 @@ function getNativeCode(err: unknown): string | undefined {
80
80
  return undefined
81
81
  }
82
82
 
83
+ function isUnknownAudioStreamCode(raw: string | undefined): boolean {
84
+ if (!raw) return false
85
+ return (
86
+ raw.toUpperCase().startsWith('ERR_AUDIO_STREAM_') &&
87
+ normalizeCode(raw) === null
88
+ )
89
+ }
90
+
83
91
  function normalizeCode(raw: string | undefined): AudioStreamErrorCode | null {
84
92
  if (!raw) return null
85
93
  const upper = raw.toUpperCase()
@@ -151,6 +159,12 @@ export function mapStreamError(
151
159
  const nativeCode = getNativeCode(err)
152
160
  const lower = nativeMessage.toLowerCase()
153
161
 
162
+ if (isUnknownAudioStreamCode(nativeCode)) {
163
+ console.warn(
164
+ `[AudioStreamError] Unknown native audio stream error code: ${nativeCode}`
165
+ )
166
+ }
167
+
154
168
  let code =
155
169
  normalizeCode(nativeCode) ??
156
170
  normalizeCode(nativeMessage) ??
@@ -39,6 +39,11 @@ export interface StreamAudioDataOptions {
39
39
  maxChunkBytes?: number
40
40
  /** Max chunks queued in native before JS ack pauses decode (default: 4). */
41
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
42
47
  /** Output PCM format; only `'float32'` supported today. */
43
48
  streamFormat?: 'float32'
44
49
  /** Abort the in-flight request. Resolves promise with `cancelled: true`. */
@@ -151,15 +156,15 @@ function toFloat32(samples: unknown): Float32Array {
151
156
  }
152
157
  if (typeof samples === 'string') {
153
158
  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)
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))
163
168
  }
164
169
  if (samples && typeof samples === 'object' && 'length' in samples) {
165
170
  // ArrayLike fallback
@@ -178,7 +183,13 @@ function base64ToBytes(input: string): Uint8Array {
178
183
  const g = globalThis as { atob?: (s: string) => string }
179
184
  if (typeof g.atob !== 'function') {
180
185
  // Buffer path for environments without atob; React Native has atob.
181
- const Buf = (globalThis as { Buffer?: { from: Function } }).Buffer
186
+ const Buf = (
187
+ globalThis as {
188
+ Buffer?: {
189
+ from: (input: string, encoding: string) => Uint8Array
190
+ }
191
+ }
192
+ ).Buffer
182
193
  if (Buf) return new Uint8Array(Buf.from(input, 'base64'))
183
194
  return new Uint8Array(0)
184
195
  }
@@ -188,35 +199,70 @@ function base64ToBytes(input: string): Uint8Array {
188
199
  return out
189
200
  }
190
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
+
191
227
  function validateOptions(options: StreamAudioDataOptions): void {
192
228
  if (!options.fileUri) {
193
- throw new AudioStreamError({
194
- code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
195
- message: 'fileUri is required',
196
- recoverable: false,
197
- })
229
+ rejectInvalidRange('fileUri is required')
198
230
  }
199
231
  if (
200
232
  options.startTimeMs !== undefined &&
201
233
  options.endTimeMs !== undefined &&
202
234
  options.startTimeMs >= options.endTimeMs
203
235
  ) {
204
- throw new AudioStreamError({
205
- code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
206
- message: 'startTimeMs must be < endTimeMs',
207
- recoverable: false,
208
- })
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')
209
243
  }
210
244
  if (
211
245
  options.chunkDurationMs !== undefined &&
212
246
  (options.chunkDurationMs < 10 || options.chunkDurationMs > 60000)
213
247
  ) {
214
- throw new AudioStreamError({
215
- code: 'ERR_AUDIO_STREAM_INVALID_RANGE',
216
- message: 'chunkDurationMs must be in [10, 60000]',
217
- recoverable: false,
218
- })
248
+ rejectInvalidRange('chunkDurationMs must be in [10, 60000]')
219
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
+
220
266
  if (
221
267
  options.streamFormat !== undefined &&
222
268
  options.streamFormat !== 'float32'
@@ -315,6 +361,7 @@ async function streamAudioDataNative(
315
361
  let processingChain: Promise<void> = Promise.resolve()
316
362
  let settled = false
317
363
  let abortListener: (() => void) | null = null
364
+ let lastProgress: StreamAudioDataProgress | null = null
318
365
 
319
366
  const finalize = () => {
320
367
  for (const sub of subs) {
@@ -413,6 +460,7 @@ async function streamAudioDataNative(
413
460
  emitter.addListener(PROGRESS_EVENT, (raw: unknown) => {
414
461
  const evt = raw as StreamAudioDataProgress
415
462
  if (evt.requestId !== requestId) return
463
+ lastProgress = evt
416
464
  callbacks.onProgress!(evt)
417
465
  })
418
466
  )
@@ -435,7 +483,10 @@ async function streamAudioDataNative(
435
483
  .then(() => {
436
484
  settle(() => {}, 'resolve', {
437
485
  requestId,
438
- durationMs: evt.durationMs,
486
+ durationMs:
487
+ evt.durationMs > 0
488
+ ? evt.durationMs
489
+ : (lastProgress?.durationMs ?? 0),
439
490
  sampleRate: evt.sampleRate,
440
491
  channels: evt.channels,
441
492
  chunks: evt.chunks ?? chunkCount,
@@ -462,7 +513,7 @@ async function streamAudioDataNative(
462
513
  .then(() => {
463
514
  settle(() => {}, 'resolve', {
464
515
  requestId,
465
- durationMs: 0,
516
+ durationMs: lastProgress?.durationMs ?? 0,
466
517
  sampleRate:
467
518
  options.targetSampleRate ??
468
519
  options.sampleRate ??
@@ -494,7 +545,7 @@ async function streamAudioDataNative(
494
545
  if (options.signal.aborted) {
495
546
  settle(() => {}, 'resolve', {
496
547
  requestId,
497
- durationMs: 0,
548
+ durationMs: lastProgress?.durationMs ?? 0,
498
549
  sampleRate:
499
550
  options.targetSampleRate ?? options.sampleRate ?? 0,
500
551
  channels: options.channels ?? 1,
@@ -547,8 +598,8 @@ async function streamAudioDataWeb(
547
598
  try {
548
599
  const processed = await processAudioBuffer({
549
600
  fileUri: options.fileUri,
550
- targetSampleRate: options.targetSampleRate ?? 16000,
551
- targetChannels: options.channels ?? 1,
601
+ targetSampleRate: options.targetSampleRate,
602
+ targetChannels: options.channels,
552
603
  normalizeAudio: options.normalizeAudio ?? true,
553
604
  startTimeMs: options.startTimeMs,
554
605
  endTimeMs: options.endTimeMs,
@@ -559,16 +610,34 @@ async function streamAudioDataWeb(
559
610
  const durationMs = processed.durationMs
560
611
  const chunkDurationMs = options.chunkDurationMs ?? 1000
561
612
  let samplesPerChunk = Math.max(
562
- 1,
613
+ channels,
563
614
  Math.floor((chunkDurationMs / 1000) * sampleRate) * channels
564
615
  )
565
616
  if (options.maxChunkBytes) {
566
- const maxSamples = Math.floor(options.maxChunkBytes / 4)
567
- samplesPerChunk = Math.min(samplesPerChunk, maxSamples)
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
+ )
568
629
  }
569
630
 
570
- const all = sanitizeFloat32(processed.channelData, options.normalizeAudio ?? true)
631
+ const all = sanitizeFloat32(
632
+ interleaveBuffer(processed.buffer, channels),
633
+ options.normalizeAudio ?? true
634
+ )
571
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
572
641
  let chunkIndex = 0
573
642
  let emittedSamples = 0
574
643
  for (let off = 0; off < all.length; off += samplesPerChunk) {
@@ -587,11 +656,15 @@ async function streamAudioDataWeb(
587
656
  const slice = all.slice(off, end)
588
657
  const startSample = off / channels
589
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
590
663
  const chunk: StreamAudioDataChunk = {
591
664
  requestId,
592
665
  chunkIndex,
593
- startTimeMs: Math.round((startSample / sampleRate) * 1000),
594
- endTimeMs: Math.round((endSample / sampleRate) * 1000),
666
+ startTimeMs: startMs,
667
+ endTimeMs: endMs,
595
668
  durationMs: Math.round(
596
669
  ((endSample - startSample) / sampleRate) * 1000
597
670
  ),
@@ -603,11 +676,24 @@ async function streamAudioDataWeb(
603
676
  isFinal: end >= all.length,
604
677
  }
605
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
606
689
  callbacks.onProgress?.({
607
690
  requestId,
608
- processedMs: chunk.endTimeMs,
691
+ processedMs: elapsedMs,
609
692
  durationMs,
610
- progress: durationMs > 0 ? chunk.endTimeMs / durationMs : 1,
693
+ progress:
694
+ durationMs > 0
695
+ ? Math.min(1, Math.max(0, elapsedMs / durationMs))
696
+ : 1,
611
697
  emittedChunks: chunkIndex + 1,
612
698
  })
613
699
  chunkIndex += 1
@@ -628,10 +714,28 @@ async function streamAudioDataWeb(
628
714
  }
629
715
  }
630
716
 
631
- function sanitizeFloat32(
632
- input: Float32Array,
633
- clamp: boolean
634
- ): Float32Array {
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 {
635
739
  if (!clamp) {
636
740
  // still need NaN/Inf sanitation
637
741
  for (let i = 0; i < input.length; i++) {
@@ -6,8 +6,8 @@ import { ConsoleLike } from '../AudioStudio.types'
6
6
  export interface ProcessAudioBufferOptions {
7
7
  arrayBuffer?: ArrayBuffer
8
8
  fileUri?: string
9
- targetSampleRate: number
10
- targetChannels: number
9
+ targetSampleRate?: number
10
+ targetChannels?: number
11
11
  normalizeAudio: boolean
12
12
  startTimeMs?: number
13
13
  endTimeMs?: number
@@ -84,9 +84,17 @@ export async function processAudioBuffer({
84
84
  // Create context at original sample rate first
85
85
  ctx =
86
86
  audioContext ||
87
- new (window.AudioContext || (window as any).webkitAudioContext)()
87
+ new (window.AudioContext ||
88
+ (
89
+ window as unknown as {
90
+ webkitAudioContext?: typeof AudioContext
91
+ }
92
+ ).webkitAudioContext)()
88
93
  buffer = await ctx.decodeAudioData(audioData)
89
94
 
95
+ const effectiveTargetSampleRate = targetSampleRate ?? buffer.sampleRate
96
+ const effectiveTargetChannels = targetChannels ?? buffer.numberOfChannels
97
+
90
98
  logger?.debug('Decoded audio buffer:', {
91
99
  originalChannels: buffer.numberOfChannels,
92
100
  originalSampleRate: buffer.sampleRate,
@@ -109,7 +117,7 @@ export async function processAudioBuffer({
109
117
  position !== undefined
110
118
  ? Math.floor(
111
119
  (position / bytesPerSample) *
112
- (buffer.sampleRate / targetSampleRate)
120
+ (buffer.sampleRate / effectiveTargetSampleRate)
113
121
  )
114
122
  : startSample
115
123
 
@@ -117,11 +125,12 @@ export async function processAudioBuffer({
117
125
  length !== undefined
118
126
  ? Math.floor(
119
127
  (length / bytesPerSample) *
120
- (buffer.sampleRate / targetSampleRate)
128
+ (buffer.sampleRate / effectiveTargetSampleRate)
121
129
  )
122
- : endTimeMs !== undefined && startTimeMs !== undefined
130
+ : endTimeMs !== undefined
123
131
  ? Math.floor(
124
- ((endTimeMs - startTimeMs) / 1000) * buffer.sampleRate
132
+ ((endTimeMs - (startTimeMs ?? 0)) / 1000) *
133
+ buffer.sampleRate
125
134
  )
126
135
  : buffer.length - adjustedStartSample
127
136
 
@@ -130,8 +139,8 @@ export async function processAudioBuffer({
130
139
  adjustedStartSample,
131
140
  samplesNeeded,
132
141
  originalSampleRate: buffer.sampleRate,
133
- targetSampleRate,
134
- conversionRatio: buffer.sampleRate / targetSampleRate,
142
+ targetSampleRate: effectiveTargetSampleRate,
143
+ conversionRatio: buffer.sampleRate / effectiveTargetSampleRate,
135
144
  expectedDurationMs: (samplesNeeded / buffer.sampleRate) * 1000,
136
145
  })
137
146
 
@@ -153,9 +162,11 @@ export async function processAudioBuffer({
153
162
 
154
163
  // Create offline context for resampling
155
164
  const offlineCtx = new OfflineAudioContext(
156
- targetChannels,
157
- Math.ceil((samplesNeeded * targetSampleRate) / buffer.sampleRate),
158
- targetSampleRate
165
+ effectiveTargetChannels,
166
+ Math.ceil(
167
+ (samplesNeeded * effectiveTargetSampleRate) / buffer.sampleRate
168
+ ),
169
+ effectiveTargetSampleRate
159
170
  )
160
171
 
161
172
  // Create source and connect
@@ -175,7 +186,7 @@ export async function processAudioBuffer({
175
186
 
176
187
  logger?.debug('Final processed audio:', {
177
188
  outputSamples: channelData.length,
178
- outputSampleRate: targetSampleRate,
189
+ outputSampleRate: effectiveTargetSampleRate,
179
190
  durationMs,
180
191
  })
181
192
 
@@ -184,7 +195,7 @@ export async function processAudioBuffer({
184
195
  channelData,
185
196
  samples: channelData.length,
186
197
  durationMs,
187
- sampleRate: targetSampleRate,
198
+ sampleRate: effectiveTargetSampleRate,
188
199
  channels: processedBuffer.numberOfChannels,
189
200
  }
190
201
  } catch (error) {