Fix memory leaks and post-playback freeze

Fixes:
1. Post-playback freeze: audioPlayerDidFinishPlaying now dispatches to main
   thread before updating @Published properties (Swift concurrency violation)

2. Unbounded waveform cache: Implement LRU eviction with 20-entry limit
   - Track last access time for each cached waveform
   - Evict oldest entry when cache is full
   - Prevents unlimited memory growth as voice notes accumulate

3. Audio buffer memory leaks: Wrap computeWaveform in autoreleasepool
   - AVAudioPCMBuffer allocations are autoreleased
   - Pool ensures buffers are freed promptly

4. Image processing memory: Add autoreleasepool around compression loops
   - Each jpegData() call creates temporary objects
   - Inner pool per iteration prevents memory spikes during quality search

Memory should now remain stable during extended use.
This commit is contained in:
jack
2025-10-15 00:39:19 +01:00
committed by islam
parent 073e22e126
commit c2a0c86542
3 changed files with 128 additions and 93 deletions
+12 -2
View File
@@ -29,6 +29,7 @@ enum ImageUtils {
#if os(iOS) #if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL { static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension) let scaled = scaledImage(image, maxDimension: maxDimension)
var quality = compressionQuality var quality = compressionQuality
guard var jpegData = scaled.jpegData(compressionQuality: quality) else { guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
@@ -36,13 +37,17 @@ enum ImageUtils {
} }
while jpegData.count > targetImageBytes && quality > 0.3 { while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1 quality -= 0.1
guard let next = scaled.jpegData(compressionQuality: quality) else { break } autoreleasepool {
if let next = scaled.jpegData(compressionQuality: quality) {
jpegData = next jpegData = next
} }
}
}
let outputURL = try makeOutputURL() let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic) try jpegData.write(to: outputURL, options: .atomic)
return outputURL return outputURL
} }
}
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size let size = image.size
@@ -58,6 +63,7 @@ enum ImageUtils {
} }
#else #else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL { static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension) let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed throw ImageUtilsError.encodingFailed
@@ -86,13 +92,17 @@ enum ImageUtils {
} }
while jpegData.count > targetImageBytes && quality > 0.3 { while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1 quality -= 0.1
guard let next = encodeJPEG(from: cgImage, quality: quality) else { break } autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next jpegData = next
} }
}
}
let outputURL = try makeOutputURL() let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic) try jpegData.write(to: outputURL, options: .atomic)
return outputURL return outputURL
} }
}
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size let size = image.size
@@ -91,11 +91,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
// MARK: - AVAudioPlayerDelegate // MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stopTimer() // Delegate callback may be on background thread - ensure main thread for UI updates
updateProgress() DispatchQueue.main.async { [weak self] in
isPlaying = false guard let self = self else { return }
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self) VoiceNotePlaybackCoordinator.shared.deactivate(self)
} }
}
// MARK: - Private Helpers // MARK: - Private Helpers
+27 -6
View File
@@ -7,19 +7,28 @@ final class WaveformCache {
static let shared = WaveformCache() static let shared = WaveformCache()
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent) private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
private var cache: [URL: [Float]] = [:] private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
private init() {} private init() {}
func cachedWaveform(for url: URL) -> [Float]? { func cachedWaveform(for url: URL) -> [Float]? {
queue.sync { cache[url] } queue.sync {
guard let entry = cache[url] else { return nil }
return entry.waveform
}
} }
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) { func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
queue.async { [weak self] in queue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if let cached = self.cache[url] {
DispatchQueue.main.async { completion(cached) } // Check cache and update access time
if let entry = self.cache[url] {
self.queue.async(flags: .barrier) { [weak self] in
self?.cache[url] = (entry.waveform, Date())
}
DispatchQueue.main.async { completion(entry.waveform) }
return return
} }
@@ -28,8 +37,17 @@ final class WaveformCache {
return return
} }
self.queue.async(flags: .barrier) { self.queue.async(flags: .barrier) { [weak self] in
self.cache[url] = computed guard let self = self else { return }
// Evict oldest entry if cache is full
if self.cache.count >= self.maxCacheSize {
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
self.cache.removeValue(forKey: oldest.key)
}
}
self.cache[url] = (computed, Date())
} }
DispatchQueue.main.async { completion(computed) } DispatchQueue.main.async { completion(computed) }
} }
@@ -49,6 +67,8 @@ final class WaveformCache {
private func computeWaveform(url: URL, bins: Int) -> [Float]? { private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil } guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
return autoreleasepool {
do { do {
let audioFile = try AVAudioFile(forReading: url) let audioFile = try AVAudioFile(forReading: url)
let length = Int(audioFile.length) let length = Int(audioFile.length)
@@ -93,3 +113,4 @@ final class WaveformCache {
} }
} }
} }
}