@siteed/audio-studio 3.0.3 → 3.0.4

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.
@@ -65,6 +65,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
65
65
  internal var lastEmittedCompressedSize: Int64 = 0
66
66
  private var totalDataSize: Int64 = 0
67
67
  private var lastBufferTime: AVAudioTime?
68
+ /// Guarded by `accumulatedDataLock`.
68
69
  private var accumulatedData = Data()
69
70
 
70
71
  // Data emission for onAudioAnalysis
@@ -73,8 +74,57 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
73
74
  internal var lastEmittedCompressedSizeAnalysis: Int64 = 0
74
75
  private var totalDataSizeAnalysis: Int64 = 0
75
76
  private var lastBufferTimeAnalysis: AVAudioTime?
77
+ /// Guarded by `accumulatedDataLock`.
76
78
  private var accumulatedAnalysisData = Data()
77
79
 
80
+ // Guards accumulatedData and accumulatedAnalysisData. The audio tap
81
+ // appends from the render thread while emit/pause/stop snapshot-and-clear
82
+ // from other threads. Without this lock, base64EncodedString crashed
83
+ // mid-flush (issue #314) and short emissions were occasionally dropped.
84
+ private let accumulatedDataLock = NSLock()
85
+
86
+ /// Append the same chunk to both buffers under the lock.
87
+ /// Called from the audio tap thread.
88
+ private func appendAccumulated(_ data: Data) {
89
+ accumulatedDataLock.lock()
90
+ accumulatedData.append(data)
91
+ accumulatedAnalysisData.append(data)
92
+ accumulatedDataLock.unlock()
93
+ }
94
+
95
+ /// Atomically snapshot accumulatedData and clear it. Empty Data if buffer was empty.
96
+ private func takeAccumulatedSnapshot() -> Data {
97
+ accumulatedDataLock.lock()
98
+ defer { accumulatedDataLock.unlock() }
99
+ let snapshot = accumulatedData
100
+ accumulatedData.removeAll()
101
+ return snapshot
102
+ }
103
+
104
+ /// Atomically snapshot accumulatedAnalysisData and clear it.
105
+ private func takeAccumulatedAnalysisSnapshot() -> Data {
106
+ accumulatedDataLock.lock()
107
+ defer { accumulatedDataLock.unlock() }
108
+ let snapshot = accumulatedAnalysisData
109
+ accumulatedAnalysisData.removeAll()
110
+ return snapshot
111
+ }
112
+
113
+ /// Clear both buffers atomically (lifecycle resets).
114
+ private func resetAccumulated() {
115
+ accumulatedDataLock.lock()
116
+ accumulatedData.removeAll()
117
+ accumulatedAnalysisData.removeAll()
118
+ accumulatedDataLock.unlock()
119
+ }
120
+
121
+ /// Current size of accumulatedData for logs/probes.
122
+ private func accumulatedDataSize() -> Int {
123
+ accumulatedDataLock.lock()
124
+ defer { accumulatedDataLock.unlock() }
125
+ return accumulatedData.count
126
+ }
127
+
78
128
 
79
129
 
80
130
  private var fileManager = FileManager.default
@@ -830,16 +880,14 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
830
880
  return false
831
881
  }
832
882
 
833
- // Reset audio session before preparing new recording
883
+ // Deactivate the session for a clean slate before reconfiguring.
884
+ // configureAudioSession() below will activate it once — no need for
885
+ // the previous deactivate→sleep→activate→reconfigure→activate cycle.
834
886
  do {
835
887
  let session = AVAudioSession.sharedInstance()
836
888
  try session.setActive(false, options: .notifyOthersOnDeactivation)
837
- Thread.sleep(forTimeInterval: 0.1) // Brief pause to ensure clean state
838
- try session.setActive(true, options: .notifyOthersOnDeactivation)
839
889
  } catch {
840
- Logger.debug("AudioStreamManager", "Failed to reset audio session: \(error)")
841
- delegate?.audioStreamManager(self, didFailWithError: "Failed to reset audio session: \(error.localizedDescription)")
842
- return false
890
+ Logger.debug("AudioStreamManager", "Failed to deactivate audio session (non-fatal): \(error)")
843
891
  }
844
892
 
845
893
  // Update auto-resume preference from settings
@@ -850,8 +898,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
850
898
  emissionIntervalAnalysis = max(10.0, Double(settings.intervalAnalysis ?? 500)) / 1000.0
851
899
  lastEmissionTime = nil // Will be set when recording starts
852
900
  lastEmissionTimeAnalysis = nil // Will be set when recording starts
853
- accumulatedData.removeAll()
854
- accumulatedAnalysisData.removeAll()
901
+ resetAccumulated()
855
902
  totalDataSize = 0
856
903
  totalDataSizeAnalysis = 0
857
904
  totalPausedDuration = 0
@@ -943,8 +990,9 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
943
990
 
944
991
  recordingSettings = newSettings // Keep original settings with desired sample rate
945
992
 
946
- audioEngine.prepare() // Prepare the engine without starting it
947
-
993
+ // audioEngine.prepare() is already called inside installTapWithHardwareFormat()
994
+ // (default prepareEngine: true). Don't call it twice.
995
+
948
996
  // Setup compressed recording if enabled
949
997
  if settings.output.compressed.enabled {
950
998
  // Create compressed settings
@@ -1010,9 +1058,18 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1010
1058
  Logger.debug("AudioStreamManager", "AudioProcessor activated successfully.")
1011
1059
  }
1012
1060
 
1013
- // Prepare notifications if enabled but don't show yet
1061
+ // initializeNotifications schedules a Timer that needs main's RunLoop,
1062
+ // and notificationManager must exist before prepareRecording returns
1063
+ // (the recursive startRecording → prepareRecording path uses it
1064
+ // immediately). .sync is safe: nothing on main blocks on audioLifecycleQueue.
1014
1065
  if settings.showNotification {
1015
- initializeNotifications()
1066
+ if Thread.isMainThread {
1067
+ initializeNotifications()
1068
+ } else {
1069
+ DispatchQueue.main.sync {
1070
+ self.initializeNotifications()
1071
+ }
1072
+ }
1016
1073
  }
1017
1074
 
1018
1075
  // Mark preparation as complete
@@ -1177,16 +1234,12 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1177
1234
  Logger.debug("Pausing recording...")
1178
1235
 
1179
1236
  // Emit any remaining audio data before pausing
1180
- if !accumulatedData.isEmpty {
1181
- Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes before pausing")
1237
+ let finalData = takeAccumulatedSnapshot()
1238
+ if !finalData.isEmpty {
1239
+ Logger.debug("Emitting final audio chunk of \(finalData.count) bytes before pausing")
1182
1240
  let recordingTime = currentRecordingDuration()
1183
1241
  let finalTotalSize = self.totalDataSize
1184
-
1185
- // Create a copy of accumulated data to avoid race conditions
1186
- let finalData = accumulatedData
1187
- accumulatedData.removeAll()
1188
-
1189
- // Notify delegate with final audio data
1242
+
1190
1243
  delegate?.audioStreamManager(
1191
1244
  self,
1192
1245
  didReceiveAudioData: finalData,
@@ -1542,9 +1595,9 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1542
1595
  }
1543
1596
 
1544
1597
  // --- Event Emission & Analysis (Always Happens) ---
1545
- // Audio streaming is independent of file output settings
1546
- accumulatedData.append(dataToWrite)
1547
- accumulatedAnalysisData.append(dataToWrite)
1598
+ // Audio streaming is independent of file output settings.
1599
+ // Lock-protected append: see accumulatedDataLock for rationale.
1600
+ appendAccumulated(dataToWrite)
1548
1601
 
1549
1602
  if recordingSettings?.showNotification == true {
1550
1603
  updateNotificationDuration()
@@ -1555,34 +1608,36 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1555
1608
 
1556
1609
  // Emit AudioData event
1557
1610
  if let lastEmission = self.lastEmissionTime {
1558
- // Log emission evaluation every 10th buffer
1559
1611
  if debugBufferCounter % 10 == 0 {
1560
1612
  let timeGap = currentTime.timeIntervalSince(lastEmission)
1561
1613
  let isTimeReady = timeGap >= emissionInterval
1562
- Logger.debug("EMISSION DEBUG: Time since last: \(timeGap)s, Threshold: \(emissionInterval)s, Ready: \(isTimeReady), DataSize: \(accumulatedData.count) bytes")
1614
+ Logger.debug("EMISSION DEBUG: Time since last: \(timeGap)s, Threshold: \(emissionInterval)s, Ready: \(isTimeReady), DataSize: \(accumulatedDataSize()) bytes")
1563
1615
  }
1564
-
1565
- if currentTime.timeIntervalSince(lastEmission) >= emissionInterval,
1566
- !accumulatedData.isEmpty {
1567
- let dataToEmit = accumulatedData
1568
- let recordingTime = currentRecordingDuration()
1569
- self.lastEmissionTime = currentTime
1570
- self.lastEmittedSize = currentTotalSize
1571
- accumulatedData.removeAll()
1572
- let compressionInfo = buildCompressionInfo()
1573
-
1574
- Logger.debug("EMISSION SUCCESS: Emitting \(dataToEmit.count) bytes at recording time \(recordingTime)s")
1575
-
1576
- delegate?.audioStreamManager(
1577
- self,
1578
- didReceiveAudioData: dataToEmit,
1579
- recordingTime: recordingTime,
1580
- totalDataSize: currentTotalSize,
1581
- compressionInfo: compressionInfo
1582
- )
1616
+
1617
+ if currentTime.timeIntervalSince(lastEmission) >= emissionInterval {
1618
+ // Atomic snapshot+clear avoids the race that crashed
1619
+ // base64EncodedString in the delegate (issue #314) and
1620
+ // stops short writes from being dropped between read
1621
+ // and clear.
1622
+ let dataToEmit = takeAccumulatedSnapshot()
1623
+ if !dataToEmit.isEmpty {
1624
+ let recordingTime = currentRecordingDuration()
1625
+ self.lastEmissionTime = currentTime
1626
+ self.lastEmittedSize = currentTotalSize
1627
+ let compressionInfo = buildCompressionInfo()
1628
+
1629
+ Logger.debug("EMISSION SUCCESS: Emitting \(dataToEmit.count) bytes at recording time \(recordingTime)s")
1630
+
1631
+ delegate?.audioStreamManager(
1632
+ self,
1633
+ didReceiveAudioData: dataToEmit,
1634
+ recordingTime: recordingTime,
1635
+ totalDataSize: currentTotalSize,
1636
+ compressionInfo: compressionInfo
1637
+ )
1638
+ }
1583
1639
  }
1584
1640
  } else {
1585
- // This case occurs when lastEmissionTime is nil (either first run or after reset)
1586
1641
  Logger.debug("EMISSION DEBUG: lastEmissionTime is nil, setting to current time")
1587
1642
  lastEmissionTime = currentTime
1588
1643
  }
@@ -1591,12 +1646,11 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1591
1646
  if let lastEmissionAnalysis = self.lastEmissionTimeAnalysis,
1592
1647
  currentTime.timeIntervalSince(lastEmissionAnalysis) >= emissionIntervalAnalysis,
1593
1648
  settings.enableProcessing,
1594
- let _ = self.audioProcessor,
1595
- !accumulatedAnalysisData.isEmpty {
1596
- let dataToAnalyze = accumulatedAnalysisData
1649
+ let _ = self.audioProcessor {
1650
+ let dataToAnalyze = takeAccumulatedAnalysisSnapshot()
1597
1651
  self.lastEmissionTimeAnalysis = currentTime
1598
- accumulatedAnalysisData.removeAll()
1599
1652
 
1653
+ if !dataToAnalyze.isEmpty {
1600
1654
  DispatchQueue.global(qos: .userInitiated).async { [weak self] in
1601
1655
  guard let self = self, let processor = self.audioProcessor, let settings = self.recordingSettings else {
1602
1656
  // Logger.debug("Analysis Dispatch SKIP: self, processor, or settings nil")
@@ -1627,6 +1681,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1627
1681
  }
1628
1682
  }
1629
1683
  }
1684
+ }
1630
1685
  // Logger.debug("Dispatched analysis task.") // Optional: Re-enable if needed
1631
1686
  }
1632
1687
  }
@@ -1744,24 +1799,23 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1744
1799
 
1745
1800
  Logger.debug("Stopping recording...")
1746
1801
 
1747
- // IMPORTANT: Emit any remaining audio data before stopping the engine
1748
- if isRecording && !accumulatedData.isEmpty {
1749
- Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes before stopping")
1750
- let recordingTime = currentRecordingDuration()
1751
- let finalTotalSize = self.totalDataSize // Use current total size
1752
-
1753
- // Create a copy of accumulated data to avoid race conditions
1754
- let finalData = accumulatedData
1755
- accumulatedData.removeAll()
1756
-
1757
- // Notify delegate with final audio data
1758
- delegate?.audioStreamManager(
1759
- self,
1760
- didReceiveAudioData: finalData,
1761
- recordingTime: recordingTime,
1762
- totalDataSize: finalTotalSize,
1763
- compressionInfo: buildCompressionInfo()
1764
- )
1802
+ // IMPORTANT: Emit any remaining audio data before stopping the engine.
1803
+ // Atomic snapshot avoids the race documented in #314.
1804
+ if isRecording {
1805
+ let finalData = takeAccumulatedSnapshot()
1806
+ if !finalData.isEmpty {
1807
+ Logger.debug("Emitting final audio chunk of \(finalData.count) bytes before stopping")
1808
+ let recordingTime = currentRecordingDuration()
1809
+ let finalTotalSize = self.totalDataSize
1810
+
1811
+ delegate?.audioStreamManager(
1812
+ self,
1813
+ didReceiveAudioData: finalData,
1814
+ recordingTime: recordingTime,
1815
+ totalDataSize: finalTotalSize,
1816
+ compressionInfo: buildCompressionInfo()
1817
+ )
1818
+ }
1765
1819
  }
1766
1820
 
1767
1821
  disableWakeLock()
@@ -1904,8 +1958,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1904
1958
  lastEmittedSize = 0
1905
1959
  lastEmittedSizeAnalysis = 0
1906
1960
  lastEmittedCompressedSize = 0
1907
- accumulatedData.removeAll()
1908
- accumulatedAnalysisData.removeAll()
1961
+ resetAccumulated()
1909
1962
  recordingUUID = nil
1910
1963
  totalDataSize = 0
1911
1964
 
@@ -1970,8 +2023,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1970
2023
  self.lastEmittedSize = 0
1971
2024
  self.lastEmittedSizeAnalysis = 0
1972
2025
  self.lastEmittedCompressedSize = 0
1973
- self.accumulatedData.removeAll()
1974
- self.accumulatedAnalysisData.removeAll()
2026
+ self.resetAccumulated()
1975
2027
  self.recordingUUID = nil
1976
2028
  self.totalDataSize = 0
1977
2029
  self.cachedWavFileSize = 0
@@ -2173,16 +2225,16 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
2173
2225
  Logger.debug("Recording was manually paused, leaving engine paused after fallback.")
2174
2226
  }
2175
2227
 
2176
- // Emit any remaining audio data from the previous device before resetting timers
2177
- if !accumulatedData.isEmpty {
2178
- Logger.debug("Emitting final audio chunk of \(accumulatedData.count) bytes from previous device")
2228
+ // Emit any remaining audio data from the previous device, then
2229
+ // wipe both buffers atomically (analysis buffer is discarded since
2230
+ // we want to start fresh on the fallback device).
2231
+ let finalData = takeAccumulatedSnapshot()
2232
+ _ = takeAccumulatedAnalysisSnapshot()
2233
+ if !finalData.isEmpty {
2234
+ Logger.debug("Emitting final audio chunk of \(finalData.count) bytes from previous device")
2179
2235
  let recordingTime = currentRecordingDuration()
2180
2236
  let finalTotalSize = self.totalDataSize
2181
-
2182
- // Create a copy of accumulated data to avoid race conditions
2183
- let finalData = accumulatedData
2184
-
2185
- // Notify delegate with final audio data from previous device
2237
+
2186
2238
  delegate?.audioStreamManager(
2187
2239
  self,
2188
2240
  didReceiveAudioData: finalData,
@@ -2191,15 +2243,10 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
2191
2243
  compressionInfo: buildCompressionInfo()
2192
2244
  )
2193
2245
  }
2194
-
2246
+
2195
2247
  // Reset emission timers to force new data emission with the fallback device
2196
2248
  lastEmissionTime = Date() // Reset to force immediate emission
2197
2249
  lastEmissionTimeAnalysis = Date() // Reset analysis timer too
2198
-
2199
- // Important: Do not reset totalDataSize here - it needs to be maintained
2200
- // We only clear the buffers to start accumulating new data from the fallback device
2201
- accumulatedData.removeAll() // Clear any partial data from previous device
2202
- accumulatedAnalysisData.removeAll() // Clear analysis data buffer
2203
2250
  Logger.debug("Emission timers reset. Current totalDataSize: \(totalDataSize)")
2204
2251
 
2205
2252
  // CRITICAL: Multiple scheduled recovery attempts
@@ -2209,14 +2256,13 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
2209
2256
  Logger.debug("FALLBACK RECOVERY: Checking for data at \(delaySeconds)s")
2210
2257
 
2211
2258
  // Force an immediate emission if data is being received but not emitted
2212
- if !self.accumulatedData.isEmpty {
2259
+ let dataToEmit = self.takeAccumulatedSnapshot()
2260
+ if !dataToEmit.isEmpty {
2213
2261
  Logger.debug("FALLBACK RECOVERY: Forcing emission from accumulated data after \(delaySeconds)s (total size: \(self.totalDataSize))")
2214
- let dataToEmit = self.accumulatedData
2215
2262
  let recordingTime = self.currentRecordingDuration()
2216
2263
  let totalSize = self.totalDataSize
2217
-
2264
+
2218
2265
  self.lastEmissionTime = Date() // Reset the emission timer
2219
- self.accumulatedData.removeAll() // Clear the buffer
2220
2266
 
2221
2267
  // Direct delegate call with accumulated data
2222
2268
  self.delegate?.audioStreamManager(