@siteed/audio-studio 3.1.1 → 3.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +134 -3
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/errors/AudioStreamError.js +152 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +61 -0
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
- package/build/cjs/index.js +7 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/streamAudioData.js +467 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- package/build/esm/errors/AudioStreamError.js +147 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +59 -0
- package/build/esm/errors/AudioStreamError.test.js.map +1 -0
- package/build/esm/index.js +3 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/streamAudioData.js +460 -0
- package/build/esm/streamAudioData.js.map +1 -0
- package/build/types/errors/AudioStreamError.d.ts +25 -0
- package/build/types/errors/AudioStreamError.d.ts.map +1 -0
- package/build/types/errors/AudioStreamError.test.d.ts +2 -0
- package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
- package/build/types/index.d.ts +5 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +114 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioStreamDecoder.swift +523 -0
- package/ios/AudioStudioModule.swift +147 -3
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
- package/package.json +1 -1
- package/src/errors/AudioStreamError.test.ts +65 -0
- package/src/errors/AudioStreamError.ts +185 -0
- package/src/index.ts +24 -0
- package/src/streamAudioData.ts +654 -0
|
@@ -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
|
+
}
|