@siteed/expo-audio-stream 1.7.2 → 1.8.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 (38) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +6 -1
  3. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +39 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +124 -12
  5. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +26 -2
  6. package/build/AudioRecorder.provider.d.ts.map +1 -1
  7. package/build/AudioRecorder.provider.js +1 -0
  8. package/build/AudioRecorder.provider.js.map +1 -1
  9. package/build/ExpoAudioStream.types.d.ts +35 -1
  10. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  11. package/build/ExpoAudioStream.types.js.map +1 -1
  12. package/build/ExpoAudioStream.web.d.ts +14 -3
  13. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  14. package/build/ExpoAudioStream.web.js +102 -38
  15. package/build/ExpoAudioStream.web.js.map +1 -1
  16. package/build/WebRecorder.web.d.ts +11 -2
  17. package/build/WebRecorder.web.d.ts.map +1 -1
  18. package/build/WebRecorder.web.js +178 -43
  19. package/build/WebRecorder.web.js.map +1 -1
  20. package/build/events.d.ts +6 -0
  21. package/build/events.d.ts.map +1 -1
  22. package/build/events.js.map +1 -1
  23. package/build/useAudioRecorder.d.ts +3 -2
  24. package/build/useAudioRecorder.d.ts.map +1 -1
  25. package/build/useAudioRecorder.js +46 -5
  26. package/build/useAudioRecorder.js.map +1 -1
  27. package/ios/AudioStreamManager.swift +127 -8
  28. package/ios/AudioStreamManagerDelegate.swift +8 -2
  29. package/ios/ExpoAudioStreamModule.swift +61 -46
  30. package/ios/RecordingResult.swift +2 -0
  31. package/ios/RecordingSettings.swift +63 -3
  32. package/package.json +1 -1
  33. package/src/AudioRecorder.provider.tsx +1 -0
  34. package/src/ExpoAudioStream.types.ts +38 -1
  35. package/src/ExpoAudioStream.web.ts +114 -38
  36. package/src/WebRecorder.web.ts +210 -64
  37. package/src/events.ts +7 -0
  38. package/src/useAudioRecorder.tsx +70 -8
@@ -9,6 +9,7 @@ struct RecordingResult {
9
9
  var channels: Int
10
10
  var bitDepth: Int
11
11
  var sampleRate: Double
12
+ var compression: CompressedRecordingInfo?
12
13
  }
13
14
 
14
15
  struct StartRecordingResult {
@@ -17,4 +18,5 @@ struct StartRecordingResult {
17
18
  var channels: Int
18
19
  var bitDepth: Int
19
20
  var sampleRate: Double
21
+ var compression: CompressedRecordingInfo?
20
22
  }
@@ -17,6 +17,27 @@ struct IOSNotificationConfig {
17
17
  var categoryIdentifier: String?
18
18
  }
19
19
 
20
+ struct CompressedRecordingInfo {
21
+ var fileUri: String
22
+ var mimeType: String
23
+ var bitrate: Int
24
+ var format: String
25
+
26
+ static func validate(format: String, bitrate: Int) -> Result<Void, Error> {
27
+ // Validate format
28
+ guard ["aac", "opus"].contains(format.lowercased()) else {
29
+ return .failure(RecordingError.unsupportedFormat(format))
30
+ }
31
+
32
+ // Validate bitrate
33
+ guard (8000...960000).contains(bitrate) else {
34
+ return .failure(RecordingError.invalidBitrate(bitrate))
35
+ }
36
+
37
+ return .success(())
38
+ }
39
+ }
40
+
20
41
  struct NotificationConfig {
21
42
  var title: String?
22
43
  var text: String?
@@ -28,6 +49,20 @@ struct IOSConfig {
28
49
  var audioSession: IOSAudioSessionConfig?
29
50
  }
30
51
 
52
+ enum RecordingError: Error {
53
+ case unsupportedFormat(String)
54
+ case invalidBitrate(Int)
55
+
56
+ var localizedDescription: String {
57
+ switch self {
58
+ case .unsupportedFormat(let format):
59
+ return "Unsupported compression format: \(format). iOS only supports AAC."
60
+ case .invalidBitrate(let bitrate):
61
+ return "Invalid bitrate: \(bitrate). Must be between 8000 and 960000 bps."
62
+ }
63
+ }
64
+ }
65
+
31
66
  struct RecordingSettings {
32
67
  // Core recording settings
33
68
  var sampleRate: Double
@@ -52,10 +87,35 @@ struct RecordingSettings {
52
87
  // Notification configuration
53
88
  var notification: NotificationConfig?
54
89
 
55
- static func fromDictionary(_ dict: [String: Any]) -> RecordingSettings {
90
+ let enableCompressedOutput: Bool
91
+ let compressedFormat: String // "aac" or "opus"
92
+ let compressedBitRate: Int
93
+
94
+ static func fromDictionary(_ dict: [String: Any]) -> Result<RecordingSettings, Error> {
95
+ // Extract compression settings
96
+ let compression = dict["compression"] as? [String: Any]
97
+ let enableCompressedOutput = compression?["enabled"] as? Bool ?? false
98
+ let compressedFormat = (compression?["format"] as? String)?.lowercased() ?? "opus"
99
+ let compressedBitRate = compression?["bitrate"] as? Int ?? 24000
100
+
101
+ // Validate compression settings if enabled
102
+ if enableCompressedOutput {
103
+ // Validate format and bitrate
104
+ if case .failure(let error) = CompressedRecordingInfo.validate(
105
+ format: compressedFormat,
106
+ bitrate: compressedBitRate
107
+ ) {
108
+ return .failure(error)
109
+ }
110
+ }
111
+
112
+ // Create settings
56
113
  var settings = RecordingSettings(
57
114
  sampleRate: dict["sampleRate"] as? Double ?? 44100.0,
58
- desiredSampleRate: dict["desiredSampleRate"] as? Double ?? 44100.0
115
+ desiredSampleRate: dict["desiredSampleRate"] as? Double ?? 44100.0,
116
+ enableCompressedOutput: enableCompressedOutput,
117
+ compressedFormat: compressedFormat,
118
+ compressedBitRate: compressedBitRate
59
119
  )
60
120
 
61
121
  // Parse core settings
@@ -152,6 +212,6 @@ struct RecordingSettings {
152
212
  settings.notification = notificationConfig
153
213
  }
154
214
 
155
- return settings
215
+ return .success(settings)
156
216
  }
157
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-stream",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "stream audio crossplatform",
5
5
  "license": "MIT",
6
6
  "main": "build/index.js",
@@ -9,6 +9,7 @@ const initContext: UseAudioRecorderState = {
9
9
  isPaused: false,
10
10
  durationMs: 0,
11
11
  size: 0,
12
+ compression: undefined,
12
13
  startRecording: async () => {
13
14
  throw new Error('AudioRecorderProvider not found')
14
15
  },
@@ -6,6 +6,13 @@ import {
6
6
  } from './AudioAnalysis/AudioAnalysis.types'
7
7
  import { AudioAnalysisEvent } from './events'
8
8
 
9
+ export interface CompressionInfo {
10
+ size: number
11
+ mimeType: string
12
+ bitrate: number
13
+ format: string
14
+ }
15
+
9
16
  export interface AudioStreamStatus {
10
17
  isRecording: boolean
11
18
  isPaused: boolean
@@ -13,6 +20,7 @@ export interface AudioStreamStatus {
13
20
  size: number
14
21
  interval: number
15
22
  mimeType: string
23
+ compression?: CompressionInfo
16
24
  }
17
25
 
18
26
  export interface AudioDataEvent {
@@ -21,6 +29,9 @@ export interface AudioDataEvent {
21
29
  fileUri: string
22
30
  eventDataSize: number
23
31
  totalSize: number
32
+ compression?: CompressionInfo & {
33
+ data?: string | Blob // Base64 (native) or Float32Array (web) encoded compressed data chunk
34
+ }
24
35
  }
25
36
 
26
37
  export type EncodingType = 'pcm_32bit' | 'pcm_16bit' | 'pcm_8bit'
@@ -60,6 +71,9 @@ export interface AudioRecording {
60
71
  transcripts?: TranscriberData[]
61
72
  wavPCMData?: Float32Array // Full PCM data for the recording in WAV format (only on web, for native use the fileUri)
62
73
  analysisData?: AudioAnalysis // Analysis data for the recording depending on enableProcessing flag
74
+ compression?: CompressionInfo & {
75
+ compressedFileUri: string
76
+ }
63
77
  }
64
78
 
65
79
  export interface StartRecordingResult {
@@ -68,6 +82,9 @@ export interface StartRecordingResult {
68
82
  channels?: number
69
83
  bitDepth?: BitDepth
70
84
  sampleRate?: SampleRate
85
+ compression?: CompressionInfo & {
86
+ compressedFileUri: string
87
+ }
71
88
  }
72
89
 
73
90
  export interface AudioSessionConfig {
@@ -147,6 +164,12 @@ export interface RecordingConfig {
147
164
 
148
165
  // Callback function to handle audio features extraction results
149
166
  onAudioAnalysis?: (_: AudioAnalysisEvent) => Promise<void>
167
+
168
+ compression?: {
169
+ enabled: boolean
170
+ format: 'aac' | 'opus' | 'mp3'
171
+ bitrate?: number
172
+ }
150
173
  }
151
174
 
152
175
  export interface NotificationConfig {
@@ -216,14 +239,28 @@ export interface WaveformConfig {
216
239
  height?: number // Height of the waveform view in dp (default: 64)
217
240
  }
218
241
 
242
+ export interface WebRecordingOptions {
243
+ /**
244
+ * Web-specific option to skip the final audio data consolidation process.
245
+ * When true, it will:
246
+ * - Skip the time-consuming process of concatenating all audio chunks
247
+ * - Return immediately with the compressed audio (if compression is enabled)
248
+ * - Improve performance when stopping large recordings
249
+ * - Useful when only the compressed audio is needed (e.g., when not using transcription)
250
+ * @default false
251
+ */
252
+ skipFinalConsolidation?: boolean
253
+ }
254
+
219
255
  export interface UseAudioRecorderState {
220
256
  startRecording: (_: RecordingConfig) => Promise<StartRecordingResult>
221
- stopRecording: () => Promise<AudioRecording | null>
257
+ stopRecording: (options?: WebRecordingOptions) => Promise<AudioRecording | null>
222
258
  pauseRecording: () => Promise<void>
223
259
  resumeRecording: () => Promise<void>
224
260
  isRecording: boolean
225
261
  isPaused: boolean
226
262
  durationMs: number // Duration of the recording
227
263
  size: number // Size in bytes of the recorded audio
264
+ compression?: CompressionInfo
228
265
  analysisData?: AudioAnalysis // Analysis data for the recording depending on enableProcessing flag
229
266
  }
@@ -9,6 +9,7 @@ import {
9
9
  ConsoleLike,
10
10
  RecordingConfig,
11
11
  StartRecordingResult,
12
+ WebRecordingOptions,
12
13
  } from './ExpoAudioStream.types'
13
14
  import { WebRecorder } from './WebRecorder.web'
14
15
  import { AudioEventPayload } from './events'
@@ -18,6 +19,14 @@ import { writeWavHeader } from './utils/writeWavHeader'
18
19
  export interface EmitAudioEventProps {
19
20
  data: Float32Array
20
21
  position: number
22
+ compression?: {
23
+ data: Blob
24
+ size: number
25
+ totalSize: number
26
+ mimeType: string
27
+ format: string
28
+ bitrate: number
29
+ }
21
30
  }
22
31
  export type EmitAudioEventFunction = (_: EmitAudioEventProps) => void
23
32
  export type EmitAudioAnalysisFunction = (_: AudioAnalysis) => void
@@ -40,6 +49,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
40
49
  currentInterval: number
41
50
  lastEmittedSize: number
42
51
  lastEmittedTime: number
52
+ lastEmittedCompressionSize: number
43
53
  streamUuid: string | null
44
54
  extension: 'webm' | 'wav' = 'wav' // Default extension is 'webm'
45
55
  recordingConfig?: RecordingConfig
@@ -47,6 +57,8 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
47
57
  audioWorkletUrl: string
48
58
  featuresExtratorUrl: string
49
59
  logger?: ConsoleLike
60
+ latestPosition: number = 0
61
+ totalCompressedSize: number = 0
50
62
 
51
63
  constructor({
52
64
  audioWorkletUrl,
@@ -76,6 +88,8 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
76
88
  this.currentInterval = 1000 // Default interval in ms
77
89
  this.lastEmittedSize = 0
78
90
  this.lastEmittedTime = 0
91
+ this.latestPosition = 0
92
+ this.lastEmittedCompressionSize = 0
79
93
  this.streamUuid = null // Initialize UUID on first recording start
80
94
  this.audioWorkletUrl = audioWorkletUrl
81
95
  this.featuresExtratorUrl = featuresExtratorUrl
@@ -86,7 +100,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
86
100
  try {
87
101
  return await navigator.mediaDevices.getUserMedia({ audio: true })
88
102
  } catch (error) {
89
- console.error('Failed to get media stream:', error)
103
+ this.logger?.error('Failed to get media stream:', error)
90
104
  throw error
91
105
  }
92
106
  }
@@ -110,6 +124,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
110
124
  const source = audioContext.createMediaStreamSource(stream)
111
125
 
112
126
  this.customRecorder = new WebRecorder({
127
+ logger: this.logger,
113
128
  audioContext,
114
129
  source,
115
130
  recordingConfig,
@@ -117,12 +132,14 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
117
132
  emitAudioEventCallback: ({
118
133
  data,
119
134
  position,
135
+ compression,
120
136
  }: EmitAudioEventProps) => {
121
137
  this.audioChunks.push(new Float32Array(data))
122
138
  this.currentSize += data.byteLength
123
- this.emitAudioEvent({ data, position })
139
+ this.emitAudioEvent({ data, position, compression })
124
140
  this.lastEmittedTime = Date.now()
125
141
  this.lastEmittedSize = this.currentSize
142
+ this.lastEmittedCompressionSize = compression?.size ?? 0
126
143
  },
127
144
  emitAudioAnalysisCallback: (audioAnalysisData: AudioAnalysis) => {
128
145
  this.logger?.log(`Emitted AudioAnalysis:`, audioAnalysisData)
@@ -146,6 +163,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
146
163
  this.isPaused = false
147
164
  this.lastEmittedSize = 0
148
165
  this.lastEmittedTime = 0
166
+ this.lastEmittedCompressionSize = 0
149
167
  this.streamUuid = Date.now().toString()
150
168
  const fileUri = `${this.streamUuid}.${this.extension}`
151
169
  const streamConfig: StartRecordingResult = {
@@ -154,69 +172,118 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
154
172
  bitDepth: this.bitDepth,
155
173
  channels: recordingConfig.channels ?? 1,
156
174
  sampleRate: recordingConfig.sampleRate ?? 44100,
175
+ compression: recordingConfig.compression
176
+ ? {
177
+ ...recordingConfig.compression,
178
+ bitrate: recordingConfig.compression?.bitrate ?? 128000,
179
+ size: 0,
180
+ mimeType: 'audio/webm',
181
+ format: recordingConfig.compression?.format ?? 'opus',
182
+ compressedFileUri: '',
183
+ }
184
+ : undefined,
157
185
  }
158
186
  return streamConfig
159
187
  }
160
188
 
161
- emitAudioEvent({ data, position }: EmitAudioEventProps) {
189
+ emitAudioEvent({ data, position, compression }: EmitAudioEventProps) {
162
190
  const fileUri = `${this.streamUuid}.${this.extension}`
191
+ if (compression?.size) {
192
+ this.lastEmittedCompressionSize = compression.size
193
+ this.totalCompressedSize = compression.totalSize
194
+ }
195
+ this.latestPosition = position
196
+ this.currentDurationMs = position * 1000 // Convert position (in seconds) to ms
197
+
163
198
  const audioEventPayload: AudioEventPayload = {
164
199
  fileUri,
165
200
  mimeType: `audio/${this.extension}`,
166
- lastEmittedSize: this.lastEmittedSize, // Since this might be continuously streaming, adjust accordingly
201
+ lastEmittedSize: this.lastEmittedSize,
167
202
  deltaSize: data.byteLength,
168
203
  position,
169
204
  totalSize: this.currentSize,
170
205
  buffer: data,
171
- streamUuid: this.streamUuid ?? '', // Generate or manage UUID for stream identification
206
+ streamUuid: this.streamUuid ?? '',
207
+ compression: compression
208
+ ? {
209
+ data: compression?.data,
210
+ totalSize: this.totalCompressedSize,
211
+ eventDataSize: compression?.size ?? 0,
212
+ position,
213
+ }
214
+ : undefined,
172
215
  }
173
216
 
174
217
  this.emit('AudioData', audioEventPayload)
175
218
  }
176
219
 
177
220
  // Stop recording
178
- async stopRecording(): Promise<AudioRecording> {
221
+ async stopRecording(options?: WebRecordingOptions): Promise<AudioRecording> {
179
222
  if (!this.customRecorder) {
180
223
  throw new Error('Recorder is not initialized')
181
224
  }
182
225
 
183
- const fullPcmBufferArray = await this.customRecorder.stop()
226
+ // Create a promise to handle the PCM data processing
227
+ return new Promise<AudioRecording>((resolve) => {
228
+ // Use requestAnimationFrame to avoid blocking the UI
229
+ requestAnimationFrame(() => {
230
+ // Move the async work inside a self-executing async function
231
+ (async () => {
232
+ const { pcmData, compressedBlob } = await this.customRecorder!.stop(options)
184
233
 
185
- this.logger?.debug(`Stopped recording`, fullPcmBufferArray)
186
- this.isRecording = false
187
- this.isPaused = false
188
- this.currentDurationMs = Date.now() - this.recordingStartTime
234
+ this.logger?.debug(`Stopped recording`, pcmData)
235
+ this.isRecording = false
236
+ this.isPaused = false
237
+ this.currentDurationMs = Date.now() - this.recordingStartTime
189
238
 
190
- // Create WAV header and combine with PCM data in one step
191
- const wavBuffer = writeWavHeader({
192
- buffer: fullPcmBufferArray.buffer,
193
- sampleRate: this.recordingConfig?.sampleRate ?? 44100,
194
- numChannels: this.recordingConfig?.channels ?? 1,
195
- bitDepth: this.bitDepth,
196
- })
239
+ // Process in the next frame to avoid blocking
240
+ requestAnimationFrame(() => {
241
+ // Rest of the code remains the same
242
+ const wavBuffer = writeWavHeader({
243
+ buffer: pcmData.buffer,
244
+ sampleRate: this.recordingConfig?.sampleRate ?? 44100,
245
+ numChannels: this.recordingConfig?.channels ?? 1,
246
+ bitDepth: this.bitDepth,
247
+ })
197
248
 
198
- // Create a cloneable copy
199
- const cloneableBuffer = wavBuffer.slice(0)
249
+ const cloneableBuffer = wavBuffer.slice(0)
200
250
 
201
- // Create blob with complete WAV data
202
- const blob = new Blob([cloneableBuffer], {
203
- type: `audio/${this.extension}`,
204
- })
205
- const fileUri = URL.createObjectURL(blob)
251
+ const blob = new Blob([cloneableBuffer], {
252
+ type: `audio/${this.extension}`,
253
+ })
254
+ const fileUri = URL.createObjectURL(blob)
206
255
 
207
- const result: AudioRecording = {
208
- fileUri,
209
- filename: `${this.streamUuid}.${this.extension}`,
210
- wavPCMData: new Float32Array(cloneableBuffer),
211
- bitDepth: this.bitDepth,
212
- channels: this.recordingConfig?.channels ?? 1,
213
- sampleRate: this.recordingConfig?.sampleRate ?? 44100,
214
- durationMs: this.currentDurationMs,
215
- size: this.currentSize,
216
- mimeType: `audio/${this.extension}`,
217
- }
256
+ let compression: AudioRecording['compression']
257
+ if (compressedBlob && this.recordingConfig?.compression?.enabled) {
258
+ const compressedUri = URL.createObjectURL(compressedBlob)
259
+ compression = {
260
+ compressedFileUri: compressedUri,
261
+ size: compressedBlob.size,
262
+ mimeType: 'audio/webm',
263
+ format: 'opus',
264
+ bitrate: this.recordingConfig.compression.bitrate ?? 128000,
265
+ }
266
+ }
218
267
 
219
- return result
268
+ resolve({
269
+ fileUri,
270
+ filename: `${this.streamUuid}.${this.extension}`,
271
+ wavPCMData: new Float32Array(cloneableBuffer),
272
+ bitDepth: this.bitDepth,
273
+ channels: this.recordingConfig?.channels ?? 1,
274
+ sampleRate: this.recordingConfig?.sampleRate ?? 44100,
275
+ durationMs: this.currentDurationMs,
276
+ size: this.currentSize,
277
+ mimeType: `audio/${this.extension}`,
278
+ compression,
279
+ })
280
+ })
281
+ })().catch((error) => {
282
+ this.logger?.error('Error in stopRecording:', error)
283
+ throw error
284
+ })
285
+ })
286
+ })
220
287
  }
221
288
 
222
289
  // Pause recording
@@ -250,10 +317,19 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
250
317
  const status: AudioStreamStatus = {
251
318
  isRecording: this.isRecording,
252
319
  isPaused: this.isPaused,
253
- durationMs: Date.now() - this.recordingStartTime,
320
+ durationMs: this.currentDurationMs,
254
321
  size: this.currentSize,
255
322
  interval: this.currentInterval,
256
323
  mimeType: `audio/${this.extension}`,
324
+ compression: this.recordingConfig?.compression?.enabled
325
+ ? {
326
+ size: this.totalCompressedSize,
327
+ mimeType: 'audio/webm',
328
+ format: this.recordingConfig.compression.format ?? 'opus',
329
+ bitrate:
330
+ this.recordingConfig.compression.bitrate ?? 128000,
331
+ }
332
+ : undefined,
257
333
  }
258
334
  return status
259
335
  }