@siteed/audio-studio 3.0.5 → 3.1.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.
@@ -14,6 +14,7 @@ import UserNotifications
14
14
 
15
15
  // Constants
16
16
  internal let WAV_HEADER_SIZE: Int64 = 44 // Standard WAV header is 44 bytes
17
+ internal let MIN_AAC_COMPRESSED_SAMPLE_RATE: Double = 44100.0
17
18
 
18
19
  // Helper to convert to little-endian byte array
19
20
  extension UInt32 {
@@ -152,6 +153,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
152
153
 
153
154
  // Add property to track auto-resume preference
154
155
  private var autoResumeAfterInterruption: Bool = false
156
+ private var pausedBySystemInterruption: Bool = false
155
157
 
156
158
  // Add these properties
157
159
  private var emissionInterval: TimeInterval = 1.0 // Default 1 second
@@ -167,6 +169,28 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
167
169
  private var cachedWavFileSize: Int64 = 0
168
170
  private var cachedCompressedFileSize: Int64 = 0
169
171
 
172
+ /// Returns an AVAudioRecorder-compatible sample rate for AAC sidecar files.
173
+ ///
174
+ /// The primary WAV path can resample emitted PCM to `settings.sampleRate`,
175
+ /// but the compressed sidecar is produced by AVAudioRecorder directly from
176
+ /// the active audio session. AVAudioRecorder fails to prepare AAC/M4A files
177
+ /// below 44.1 kHz, so keep valid requested rates and otherwise use the
178
+ /// active session rate when available, falling back to 44.1 kHz.
179
+ internal static func compatibleAACCompressedSampleRate(
180
+ requestedSampleRate: Double,
181
+ sessionSampleRate: Double
182
+ ) -> Double {
183
+ if requestedSampleRate >= MIN_AAC_COMPRESSED_SAMPLE_RATE {
184
+ return requestedSampleRate
185
+ }
186
+
187
+ if sessionSampleRate.isFinite && sessionSampleRate >= MIN_AAC_COMPRESSED_SAMPLE_RATE {
188
+ return sessionSampleRate
189
+ }
190
+
191
+ return MIN_AAC_COMPRESSED_SAMPLE_RATE
192
+ }
193
+
170
194
  /// Initializes the AudioStreamManager
171
195
  override init() {
172
196
  super.init()
@@ -231,7 +255,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
231
255
  // Store the pause start time if not already paused
232
256
  if !wasSuspended {
233
257
  currentPauseStart = Date()
234
- pauseRecording()
258
+ pauseRecording(isSystemInterruption: true)
235
259
  }
236
260
 
237
261
  // Always notify delegate of interruption
@@ -248,17 +272,17 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
248
272
  if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
249
273
  let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
250
274
  Logger.debug("AudioStreamManager", "Interruption options - shouldResume: \(options.contains(.shouldResume))")
251
-
252
- // Calculate pause duration if we have a pause start time
253
- if let pauseStart = currentPauseStart {
254
- let pauseDuration = Date().timeIntervalSince(pauseStart)
255
- totalPausedDuration += pauseDuration
256
- currentPauseStart = nil
257
- Logger.debug("AudioStreamManager", "Added interruption pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
258
- }
259
-
260
- // For phone calls, we should auto-resume if enabled, regardless of previous pause state
261
- if autoResumeAfterInterruption && isRecording {
275
+
276
+ // Auto-resume only if this interruption paused the recording.
277
+ // If the user had already paused, preserve that intent.
278
+ // Keep currentPauseStart active until the actual resume so duration accounting
279
+ // excludes the full paused interval, including any post-interruption delay.
280
+ if AutoResumePolicy.shouldAutoResume(
281
+ autoResumeAfterInterruption: autoResumeAfterInterruption,
282
+ isRecording: isRecording,
283
+ isPaused: isPaused,
284
+ pausedBySystemInterruption: pausedBySystemInterruption
285
+ ) {
262
286
  // Add a longer delay for phone calls and ensure proper session setup
263
287
  DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
264
288
  guard let self = self else { return }
@@ -484,6 +508,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
484
508
  // If we can't restart, officially pause the recording
485
509
  if !isPaused {
486
510
  isPaused = true
511
+ pausedBySystemInterruption = false
487
512
  // Notify delegate
488
513
  delegate?.audioStreamManager(self, didPauseRecording: Date())
489
514
  }
@@ -906,6 +931,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
906
931
  lastEmittedCompressedSize = 0
907
932
  lastEmittedCompressedSizeAnalysis = 0
908
933
  isPaused = false
934
+ pausedBySystemInterruption = false
909
935
 
910
936
  // Create recording file first (unless primary output is disabled)
911
937
  if settings.output.primary.enabled {
@@ -995,10 +1021,27 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
995
1021
 
996
1022
  // Setup compressed recording if enabled
997
1023
  if settings.output.compressed.enabled {
1024
+ let isAACCompressedOutput = settings.output.compressed.format == "aac"
1025
+ let compressedSampleRate: Double
1026
+ if isAACCompressedOutput {
1027
+ compressedSampleRate = Self.compatibleAACCompressedSampleRate(
1028
+ requestedSampleRate: settings.sampleRate,
1029
+ sessionSampleRate: session.sampleRate
1030
+ )
1031
+ } else {
1032
+ compressedSampleRate = settings.sampleRate
1033
+ }
1034
+ if isAACCompressedOutput && compressedSampleRate != settings.sampleRate {
1035
+ Logger.debug(
1036
+ "AudioStreamManager",
1037
+ "Adjusted compressed AAC sample rate from \(settings.sampleRate)Hz to \(compressedSampleRate)Hz for AVAudioRecorder compatibility"
1038
+ )
1039
+ }
1040
+
998
1041
  // Create compressed settings
999
1042
  let compressedSettings: [String: Any] = [
1000
- AVFormatIDKey: settings.output.compressed.format == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
1001
- AVSampleRateKey: Float64(settings.sampleRate),
1043
+ AVFormatIDKey: isAACCompressedOutput ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
1044
+ AVSampleRateKey: compressedSampleRate,
1002
1045
  AVNumberOfChannelsKey: settings.numberOfChannels,
1003
1046
  AVEncoderBitRateKey: settings.output.compressed.bitrate,
1004
1047
  AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
@@ -1135,6 +1178,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1135
1178
  lastEmissionTimeAnalysis = Date()
1136
1179
  isRecording = true
1137
1180
  isPaused = false
1181
+ pausedBySystemInterruption = false
1138
1182
 
1139
1183
  // Start the audio engine
1140
1184
  try audioEngine.start()
@@ -1221,6 +1265,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1221
1265
  compressedFileURL = nil // Restore
1222
1266
  audioProcessor = nil // Restore
1223
1267
  recordingSettings = nil
1268
+ pausedBySystemInterruption = false
1224
1269
  isPrepared = false // Restore
1225
1270
  // --- End restored lines and removed log ---
1226
1271
 
@@ -1228,7 +1273,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1228
1273
  }
1229
1274
 
1230
1275
  /// Pauses the current audio recording.
1231
- func pauseRecording() {
1276
+ func pauseRecording(isSystemInterruption: Bool = false) {
1232
1277
  guard isRecording, !isPaused else { return }
1233
1278
 
1234
1279
  Logger.debug("Pausing recording...")
@@ -1254,6 +1299,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1254
1299
 
1255
1300
  // Update state
1256
1301
  isPaused = true
1302
+ pausedBySystemInterruption = isSystemInterruption
1257
1303
 
1258
1304
  // Stop the engine but don't remove the tap
1259
1305
  audioEngine.pause()
@@ -1302,6 +1348,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1302
1348
 
1303
1349
  // Update state
1304
1350
  isPaused = false
1351
+ pausedBySystemInterruption = false
1305
1352
 
1306
1353
  // Update notification state if enabled
1307
1354
  if recordingSettings?.showNotification == true {
@@ -1850,6 +1897,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1850
1897
  let wasRecording = isRecording
1851
1898
  isRecording = false
1852
1899
  isPaused = false
1900
+ pausedBySystemInterruption = false
1853
1901
  isPrepared = false // Reset preparation state
1854
1902
 
1855
1903
  // If we were only prepared but never started recording, clean up and return nil
@@ -2352,6 +2400,22 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
2352
2400
  }
2353
2401
  }
2354
2402
 
2403
+ internal struct AutoResumePolicy {
2404
+ /// Auto-resume only when a system interruption caused the pause.
2405
+ /// User-initiated pauses must remain paused after the interruption ends.
2406
+ static func shouldAutoResume(
2407
+ autoResumeAfterInterruption: Bool,
2408
+ isRecording: Bool,
2409
+ isPaused: Bool,
2410
+ pausedBySystemInterruption: Bool
2411
+ ) -> Bool {
2412
+ return autoResumeAfterInterruption &&
2413
+ isRecording &&
2414
+ isPaused &&
2415
+ pausedBySystemInterruption
2416
+ }
2417
+ }
2418
+
2355
2419
  extension AudioStreamManager: UNUserNotificationCenterDelegate {
2356
2420
  func userNotificationCenter(
2357
2421
  _ center: UNUserNotificationCenter,
@@ -22,6 +22,46 @@ class CompressedOnlyOutputTests: XCTestCase {
22
22
  }
23
23
 
24
24
  // MARK: - Test Compressed-Only Output (Issue #244)
25
+
26
+ func testAACCompressedSampleRateFallsBackForLowRequestedRate() {
27
+ XCTAssertEqual(
28
+ AudioStreamManager.compatibleAACCompressedSampleRate(
29
+ requestedSampleRate: 16000,
30
+ sessionSampleRate: 48000
31
+ ),
32
+ 48000,
33
+ "Low requested sample rates should use the active session rate when it is AAC-compatible"
34
+ )
35
+
36
+ XCTAssertEqual(
37
+ AudioStreamManager.compatibleAACCompressedSampleRate(
38
+ requestedSampleRate: 16000,
39
+ sessionSampleRate: 0
40
+ ),
41
+ 44100,
42
+ "Low requested sample rates should never be passed directly to AVAudioRecorder for AAC"
43
+ )
44
+ }
45
+
46
+ func testAACCompressedSampleRateKeepsCompatibleRequestedRate() {
47
+ XCTAssertEqual(
48
+ AudioStreamManager.compatibleAACCompressedSampleRate(
49
+ requestedSampleRate: 44100,
50
+ sessionSampleRate: 48000
51
+ ),
52
+ 44100,
53
+ "Already-compatible requested sample rates should preserve existing behavior"
54
+ )
55
+
56
+ XCTAssertEqual(
57
+ AudioStreamManager.compatibleAACCompressedSampleRate(
58
+ requestedSampleRate: 48000,
59
+ sessionSampleRate: 44100
60
+ ),
61
+ 48000,
62
+ "High requested sample rates should not be reduced to the session rate"
63
+ )
64
+ }
25
65
 
26
66
  func testCompressedOnlyOutputWithAAC() {
27
67
  // Given: Recording settings with primary disabled and compressed enabled (AAC)
@@ -291,4 +331,4 @@ class TestAudioStreamDelegate: AudioStreamManagerDelegate {
291
331
  func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String) {
292
332
  onError?(error)
293
333
  }
294
- }
334
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/audio-studio",
3
- "version": "3.0.5",
3
+ "version": "3.1.0",
4
4
  "description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -160,7 +160,10 @@ export interface AudioRecording {
160
160
  createdAt?: number
161
161
  /** Array of transcription data if available */
162
162
  transcripts?: TranscriberData[]
163
- /** Analysis data for the recording if processing was enabled */
163
+ /**
164
+ * Full analysis data for the recording if processing was enabled and
165
+ * `keepFullAnalysis` was not set to `false`.
166
+ */
164
167
  analysisData?: AudioAnalysis
165
168
  /** Information about compression if enabled, including the URI to the compressed file */
166
169
  compression?: CompressionInfo & {
@@ -432,6 +435,17 @@ export interface RecordingConfig {
432
435
  /** Enable audio processing (default is false) */
433
436
  enableProcessing?: boolean
434
437
 
438
+ /**
439
+ * Whether `useAudioRecorder` should retain every audio-analysis data point
440
+ * and attach the full history to `stopRecording().analysisData`.
441
+ *
442
+ * Defaults to `true` for backwards compatibility. Set to `false` for
443
+ * long-running recordings when you only need live `analysisData` state or
444
+ * per-callback `onAudioAnalysis` chunks; this avoids unbounded JS memory
445
+ * growth in the hook without disabling native analysis processing.
446
+ */
447
+ keepFullAnalysis?: boolean
448
+
435
449
  /** iOS-specific configuration */
436
450
  ios?: IOSConfig
437
451
 
@@ -156,6 +156,10 @@ interface HandleAudioAnalysisProps {
156
156
  visualizationDuration: number
157
157
  }
158
158
 
159
+ function shouldKeepFullAnalysis(config?: RecordingConfig | null): boolean {
160
+ return config?.keepFullAnalysis !== false
161
+ }
162
+
159
163
  export function useAudioRecorder({
160
164
  logger,
161
165
  audioWorkletUrl,
@@ -232,10 +236,15 @@ export function useAudioRecorder({
232
236
  ...analysis.dataPoints,
233
237
  ]
234
238
 
235
- const fullCombinedDataPoints = [
236
- ...(fullAnalysisRef.current?.dataPoints ?? []),
237
- ...analysis.dataPoints,
238
- ]
239
+ const keepFullAnalysis = shouldKeepFullAnalysis(
240
+ recordingConfigRef.current
241
+ )
242
+ const fullCombinedDataPoints = keepFullAnalysis
243
+ ? [
244
+ ...(fullAnalysisRef.current?.dataPoints ?? []),
245
+ ...analysis.dataPoints,
246
+ ]
247
+ : undefined
239
248
 
240
249
  // Calculate the new duration
241
250
  // The number of segments is based on how many segments of segmentDurationMs can fit in visualizationDuration
@@ -257,13 +266,15 @@ export function useAudioRecorder({
257
266
  )
258
267
  }
259
268
 
260
- // Keep the full data points
261
- fullAnalysisRef.current = {
262
- ...fullAnalysisRef.current,
263
- dataPoints: fullCombinedDataPoints,
269
+ // Keep the full data points when requested for stopRecording().analysisData.
270
+ if (keepFullAnalysis && fullCombinedDataPoints) {
271
+ fullAnalysisRef.current = {
272
+ ...fullAnalysisRef.current,
273
+ dataPoints: fullCombinedDataPoints,
274
+ }
275
+ fullAnalysisRef.current.durationMs =
276
+ fullCombinedDataPoints.length * analysis.segmentDurationMs
264
277
  }
265
- fullAnalysisRef.current.durationMs =
266
- fullCombinedDataPoints.length * analysis.segmentDurationMs
267
278
  savedAnalysisData.dataPoints = combinedDataPoints
268
279
  savedAnalysisData.bitDepth =
269
280
  analysis.bitDepth || savedAnalysisData.bitDepth
@@ -284,9 +295,11 @@ export function useAudioRecorder({
284
295
  min: newMin,
285
296
  max: newMax,
286
297
  }
287
- fullAnalysisRef.current.amplitudeRange = {
288
- min: newMin,
289
- max: newMax,
298
+ if (keepFullAnalysis) {
299
+ fullAnalysisRef.current.amplitudeRange = {
300
+ min: newMin,
301
+ max: newMax,
302
+ }
290
303
  }
291
304
 
292
305
  logger?.debug(
@@ -523,6 +536,7 @@ export function useAudioRecorder({
523
536
  onAudioStream,
524
537
  onRecordingInterrupted,
525
538
  onAudioAnalysis,
539
+ keepFullAnalysis: _keepFullAnalysis,
526
540
  ...options
527
541
  } = validatedOptions
528
542
  const { enableProcessing } = options
@@ -579,6 +593,7 @@ export function useAudioRecorder({
579
593
  onAudioStream,
580
594
  onRecordingInterrupted,
581
595
  onAudioAnalysis,
596
+ keepFullAnalysis: _keepFullAnalysis,
582
597
  ...options
583
598
  } = recordingOptions
584
599
 
@@ -603,7 +618,14 @@ export function useAudioRecorder({
603
618
  logger?.debug(`stoping recording`)
604
619
 
605
620
  const stopResult: AudioRecording = await audioStudio.stopRecording()
606
- stopResult.analysisData = fullAnalysisRef.current
621
+ if (shouldKeepFullAnalysis(recordingConfigRef.current)) {
622
+ stopResult.analysisData = fullAnalysisRef.current
623
+ } else {
624
+ // `keepFullAnalysis` is a hook-level retention policy. If a platform
625
+ // starts returning native analysisData in the future, keep opt-out
626
+ // semantics explicit and avoid leaking a full history here.
627
+ delete stopResult.analysisData
628
+ }
607
629
 
608
630
  if (analysisListenerRef.current) {
609
631
  analysisListenerRef.current.remove()