mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:25:22 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user