@siteed/expo-audio-stream 1.12.2 → 1.13.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 (30) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +866 -70
  3. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +4 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +30 -9
  5. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +163 -24
  6. package/build/AudioAnalysis/AudioAnalysis.types.d.ts +62 -0
  7. package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  8. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  9. package/build/AudioAnalysis/extractAudioAnalysis.d.ts +10 -1
  10. package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -1
  11. package/build/AudioAnalysis/extractAudioAnalysis.js +158 -0
  12. package/build/AudioAnalysis/extractAudioAnalysis.js.map +1 -1
  13. package/build/index.d.ts +3 -2
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js +2 -2
  16. package/build/index.js.map +1 -1
  17. package/build/useAudioRecorder.d.ts.map +1 -1
  18. package/build/useAudioRecorder.js +35 -16
  19. package/build/useAudioRecorder.js.map +1 -1
  20. package/ios/AudioProcessor.swift +391 -1
  21. package/ios/ExpoAudioStreamModule.swift +100 -0
  22. package/ios/Features.swift +30 -0
  23. package/package.json +1 -1
  24. package/plugin/build/index.d.ts +0 -1
  25. package/plugin/build/index.js +0 -5
  26. package/plugin/src/index.ts +0 -6
  27. package/src/AudioAnalysis/AudioAnalysis.types.ts +66 -0
  28. package/src/AudioAnalysis/extractAudioAnalysis.ts +219 -0
  29. package/src/index.ts +12 -1
  30. package/src/useAudioRecorder.tsx +37 -16
@@ -6,6 +6,9 @@ import {
6
6
  AmplitudeAlgorithm,
7
7
  AudioAnalysis,
8
8
  AudioFeaturesOptions,
9
+ AudioPreview,
10
+ DecodingConfig,
11
+ PreviewOptions,
9
12
  } from './AudioAnalysis.types'
10
13
  import { convertPCMToFloat32 } from '../utils/convertPCMToFloat32'
11
14
  import { getWavFileInfo, WavFileInfo } from '../utils/getWavFileInfo'
@@ -27,6 +30,175 @@ export interface ExtractAudioAnalysisProps {
27
30
  features?: AudioFeaturesOptions
28
31
  featuresExtratorUrl?: string
29
32
  logger?: ConsoleLike
33
+ decodingOptions?: DecodingConfig
34
+ }
35
+
36
+ export interface ExtractAudioFromAnyFormatProps
37
+ extends ExtractAudioAnalysisProps {
38
+ mimeType?: string
39
+ decodingOptions?: DecodingConfig
40
+ startTime?: number // Add start time in milliseconds
41
+ endTime?: number // Add end time in milliseconds
42
+ }
43
+
44
+ export async function extractAudioFromAnyFormat({
45
+ fileUri,
46
+ arrayBuffer,
47
+ mimeType,
48
+ decodingOptions,
49
+ startTime,
50
+ endTime,
51
+ ...restProps
52
+ }: ExtractAudioFromAnyFormatProps): Promise<AudioAnalysis> {
53
+ if (isWeb) {
54
+ try {
55
+ // Get the audio data
56
+ let audioBuffer: ArrayBuffer
57
+ if (arrayBuffer) {
58
+ audioBuffer = arrayBuffer
59
+ } else if (fileUri) {
60
+ const response = await fetch(fileUri)
61
+ if (!response.ok) {
62
+ throw new Error(
63
+ `Failed to fetch fileUri: ${response.statusText}`
64
+ )
65
+ }
66
+ audioBuffer = await response.arrayBuffer()
67
+ } else {
68
+ throw new Error(
69
+ 'Either arrayBuffer or fileUri must be provided'
70
+ )
71
+ }
72
+
73
+ // Create audio context with target sample rate if specified
74
+ const audioContext = new (window.AudioContext ||
75
+ (window as any).webkitAudioContext)({
76
+ sampleRate: decodingOptions?.targetSampleRate,
77
+ })
78
+
79
+ // Decode the audio data
80
+ const decodedAudioBuffer =
81
+ await audioContext.decodeAudioData(audioBuffer)
82
+
83
+ // Calculate the actual duration in milliseconds
84
+ const fullDurationMs = decodedAudioBuffer.duration * 1000
85
+ const effectiveDurationMs = endTime
86
+ ? endTime - (startTime || 0)
87
+ : fullDurationMs - (startTime || 0)
88
+
89
+ // Create a new buffer for the selected range
90
+ const rangeLength = decodedAudioBuffer.length
91
+ const rangeBuffer = new AudioBuffer({
92
+ length: rangeLength,
93
+ numberOfChannels: decodedAudioBuffer.numberOfChannels,
94
+ sampleRate: decodedAudioBuffer.sampleRate,
95
+ })
96
+
97
+ // Copy the selected range
98
+ for (
99
+ let channel = 0;
100
+ channel < decodedAudioBuffer.numberOfChannels;
101
+ channel++
102
+ ) {
103
+ const channelData = decodedAudioBuffer.getChannelData(channel)
104
+ const rangeData = channelData.slice(0, rangeLength)
105
+ rangeBuffer.copyToChannel(rangeData, channel)
106
+ }
107
+
108
+ // Use the range buffer instead of the full buffer
109
+ let processedBuffer = rangeBuffer
110
+
111
+ // Get original properties
112
+ const originalSampleRate = processedBuffer.sampleRate
113
+ const length = processedBuffer.length
114
+
115
+ // Determine target format
116
+ const targetChannels =
117
+ decodingOptions?.targetChannels ??
118
+ processedBuffer.numberOfChannels
119
+ const targetSampleRate =
120
+ decodingOptions?.targetSampleRate ?? processedBuffer.sampleRate
121
+
122
+ // Create offline context for resampling if needed
123
+ if (targetSampleRate !== originalSampleRate) {
124
+ const offlineCtx = new OfflineAudioContext(
125
+ targetChannels,
126
+ (length * targetSampleRate) / originalSampleRate,
127
+ targetSampleRate
128
+ )
129
+ const source = offlineCtx.createBufferSource()
130
+ source.buffer = processedBuffer
131
+ source.connect(offlineCtx.destination)
132
+ source.start()
133
+ processedBuffer = await offlineCtx.startRendering()
134
+ }
135
+
136
+ // Convert to the desired format
137
+ const newLength = processedBuffer.length
138
+ let wavBuffer: Float32Array | Int16Array | Int8Array
139
+
140
+ // Create appropriate buffer based on target bit depth
141
+ switch (decodingOptions?.targetBitDepth) {
142
+ case 16:
143
+ wavBuffer = new Int16Array(newLength * targetChannels)
144
+ break
145
+ case 8:
146
+ wavBuffer = new Int8Array(newLength * targetChannels)
147
+ break
148
+ case 32:
149
+ default:
150
+ wavBuffer = new Float32Array(newLength * targetChannels)
151
+ break
152
+ }
153
+
154
+ // Interleave channels and handle bit depth conversion
155
+ const numChannels = Math.min(
156
+ processedBuffer.numberOfChannels,
157
+ targetChannels
158
+ )
159
+ for (let channel = 0; channel < numChannels; channel++) {
160
+ const channelData = processedBuffer.getChannelData(channel)
161
+ for (let i = 0; i < newLength; i++) {
162
+ let sample = channelData[i]
163
+
164
+ // Normalize if requested
165
+ if (decodingOptions?.normalizeAudio) {
166
+ sample = Math.max(-1, Math.min(1, sample))
167
+ }
168
+
169
+ // Convert sample based on target bit depth
170
+ if (decodingOptions?.targetBitDepth === 16) {
171
+ sample = sample * 32767 // Convert to 16-bit range
172
+ } else if (decodingOptions?.targetBitDepth === 8) {
173
+ sample = sample * 127 // Convert to 8-bit range
174
+ }
175
+
176
+ wavBuffer[i * targetChannels + channel] = sample
177
+ }
178
+ }
179
+
180
+ // Pass the duration to extractAudioAnalysis
181
+ return await extractAudioAnalysis({
182
+ arrayBuffer: wavBuffer.buffer as ArrayBuffer,
183
+ bitDepth: decodingOptions?.targetBitDepth ?? 32,
184
+ skipWavHeader: true,
185
+ sampleRate: targetSampleRate,
186
+ numberOfChannels: targetChannels,
187
+ durationMs: effectiveDurationMs,
188
+ ...restProps,
189
+ })
190
+ } catch (error) {
191
+ console.error('Failed to process audio:', error)
192
+ throw error
193
+ }
194
+ } else {
195
+ // For native platforms, pass through all options
196
+ return await extractAudioAnalysis({
197
+ fileUri,
198
+ decodingOptions,
199
+ ...restProps,
200
+ })
201
+ }
30
202
  }
31
203
 
32
204
  export const extractAudioAnalysis = async ({
@@ -145,3 +317,50 @@ export const extractAudioAnalysis = async ({
145
317
  return res
146
318
  }
147
319
  }
320
+
321
+ export async function extractPreview({
322
+ fileUri,
323
+ numberOfPoints,
324
+ algorithm = 'rms',
325
+ startTime,
326
+ endTime,
327
+ decodingOptions,
328
+ }: PreviewOptions): Promise<AudioPreview> {
329
+ if (isWeb) {
330
+ // For web, we can reuse the existing extractAudioFromAnyFormat with modified parameters
331
+ const analysis = await extractAudioFromAnyFormat({
332
+ fileUri,
333
+ algorithm,
334
+ decodingOptions,
335
+ startTime, // Pass startTime
336
+ endTime, // Pass endTime
337
+ pointsPerSecond:
338
+ (numberOfPoints ?? 100) /
339
+ ((endTime ? endTime - (startTime || 0) : 1000) / 1000), // Adjust points per second calculation
340
+ })
341
+
342
+ // Convert AudioAnalysis to AudioPreview format and adjust duration
343
+ return {
344
+ pointsPerSecond: analysis.pointsPerSecond,
345
+ durationMs: endTime
346
+ ? endTime - (startTime || 0)
347
+ : analysis.durationMs, // Use range duration if specified
348
+ amplitudeRange: analysis.amplitudeRange,
349
+ dataPoints: analysis.dataPoints.map((point) => ({
350
+ id: point.id,
351
+ amplitude: point.amplitude,
352
+ startTime: point.startTime,
353
+ endTime: point.endTime,
354
+ })),
355
+ }
356
+ }
357
+
358
+ return await ExpoAudioStreamModule.extractPreview({
359
+ fileUri,
360
+ numberOfPoints,
361
+ algorithm,
362
+ startTime,
363
+ endTime,
364
+ decodingOptions,
365
+ })
366
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // src/index.ts
2
2
 
3
- import { extractAudioAnalysis } from './AudioAnalysis/extractAudioAnalysis'
3
+ import {
4
+ extractAudioAnalysis,
5
+ extractAudioFromAnyFormat,
6
+ extractPreview,
7
+ } from './AudioAnalysis/extractAudioAnalysis'
4
8
  import {
5
9
  AudioRecorderProvider,
6
10
  useSharedAudioRecorder,
@@ -16,9 +20,16 @@ export {
16
20
  AudioRecorderProvider,
17
21
  ExpoAudioStreamModule,
18
22
  extractAudioAnalysis,
23
+ extractAudioFromAnyFormat,
24
+ extractPreview,
19
25
  useAudioRecorder,
20
26
  useSharedAudioRecorder,
21
27
  }
22
28
 
23
29
  export type * from './AudioAnalysis/AudioAnalysis.types'
30
+
24
31
  export type * from './ExpoAudioStream.types'
32
+ export type {
33
+ ExtractAudioAnalysisProps,
34
+ ExtractAudioFromAnyFormatProps,
35
+ } from './AudioAnalysis/extractAudioAnalysis'
@@ -178,6 +178,14 @@ export function useAudioRecorder({
178
178
  ((_: AudioDataEvent) => Promise<void>) | null
179
179
  >(null)
180
180
 
181
+ const stateRef = useRef({
182
+ isRecording: false,
183
+ isPaused: false,
184
+ durationMs: 0,
185
+ size: 0,
186
+ compression: undefined as CompressionInfo | undefined,
187
+ })
188
+
181
189
  const handleAudioAnalysis = useCallback(
182
190
  async ({
183
191
  analysis,
@@ -373,15 +381,17 @@ export function useAudioRecorder({
373
381
  try {
374
382
  const status: AudioStreamStatus = ExpoAudioStream.status()
375
383
  logger?.debug(
376
- `Status: paused: ${status.isPaused} durationMs: ${status.durationMs} size: ${status.size}`,
384
+ `Status: paused: ${status.isPaused} isRecording: ${status.isRecording} durationMs: ${status.durationMs} size: ${status.size}`,
377
385
  status.compression
378
386
  )
379
387
 
380
388
  // Only dispatch if values actually changed
381
389
  if (
382
- status.isRecording !== state.isRecording ||
383
- status.isPaused !== state.isPaused
390
+ status.isRecording !== stateRef.current.isRecording ||
391
+ status.isPaused !== stateRef.current.isPaused
384
392
  ) {
393
+ stateRef.current.isRecording = status.isRecording
394
+ stateRef.current.isPaused = status.isPaused
385
395
  dispatch({
386
396
  type: 'UPDATE_RECORDING_STATE',
387
397
  payload: {
@@ -391,13 +401,13 @@ export function useAudioRecorder({
391
401
  })
392
402
  }
393
403
 
394
- // Only dispatch if progress values changed
395
404
  if (
396
- status.durationMs !== state.durationMs ||
397
- status.size !== state.size ||
398
- JSON.stringify(status.compression) !==
399
- JSON.stringify(state.compression)
405
+ status.durationMs !== stateRef.current.durationMs ||
406
+ status.size !== stateRef.current.size
400
407
  ) {
408
+ stateRef.current.durationMs = status.durationMs
409
+ stateRef.current.size = status.size
410
+ stateRef.current.compression = status.compression
401
411
  dispatch({
402
412
  type: 'UPDATE_STATUS',
403
413
  payload: {
@@ -410,6 +420,17 @@ export function useAudioRecorder({
410
420
  } catch (error) {
411
421
  logger?.error(`Error getting status:`, error)
412
422
  }
423
+ }, [ExpoAudioStream, logger]) // Only depend on ExpoAudioStream and logger
424
+
425
+ // Update ref when state changes
426
+ useEffect(() => {
427
+ stateRef.current = {
428
+ isRecording: state.isRecording,
429
+ isPaused: state.isPaused,
430
+ durationMs: state.durationMs,
431
+ size: state.size,
432
+ compression: state.compression,
433
+ }
413
434
  }, [
414
435
  state.isRecording,
415
436
  state.isPaused,
@@ -499,18 +520,18 @@ export function useAudioRecorder({
499
520
  useEffect(() => {
500
521
  let intervalId: NodeJS.Timeout | undefined
501
522
 
502
- const runInterval = () => {
503
- if (state.isRecording || state.isPaused) {
504
- checkStatus()
505
- intervalId = setTimeout(runInterval, 1000)
506
- }
507
- }
523
+ if (state.isRecording || state.isPaused) {
524
+ // Immediately check status when starting
525
+ checkStatus()
508
526
 
509
- runInterval()
527
+ // Start interval
528
+ intervalId = setInterval(checkStatus, 1000)
529
+ }
510
530
 
511
531
  return () => {
512
532
  if (intervalId) {
513
- clearTimeout(intervalId)
533
+ clearInterval(intervalId)
534
+ intervalId = undefined
514
535
  }
515
536
  }
516
537
  }, [checkStatus, state.isRecording, state.isPaused])