mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +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,19 +29,24 @@ 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 {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
return try autoreleasepool {
|
||||||
var quality = compressionQuality
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
var quality = compressionQuality
|
||||||
throw ImageUtilsError.encodingFailed
|
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
}
|
}
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
guard let next = scaled.jpegData(compressionQuality: quality) else { break }
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||||
@@ -58,40 +63,45 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
return try autoreleasepool {
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
throw ImageUtilsError.encodingFailed
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
let width = inputCG.width
|
||||||
|
let height = inputCG.height
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
}
|
}
|
||||||
let width = inputCG.width
|
|
||||||
let height = inputCG.height
|
|
||||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
|
||||||
guard let context = CGContext(
|
|
||||||
data: nil,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
bitsPerComponent: 8,
|
|
||||||
bytesPerRow: 0,
|
|
||||||
space: colorSpace,
|
|
||||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
||||||
) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
guard let cgImage = context.makeImage() else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
guard let next = encodeJPEG(from: cgImage, quality: quality) else { break }
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||||
|
|||||||
@@ -91,10 +91,14 @@ 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 }
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
self.stopTimer()
|
||||||
|
self.updateProgress()
|
||||||
|
self.isPlaying = false
|
||||||
|
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,47 +67,50 @@ 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 }
|
||||||
do {
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
let audioFile = try AVAudioFile(forReading: url)
|
return autoreleasepool {
|
||||||
let length = Int(audioFile.length)
|
do {
|
||||||
guard length > 0 else { return nil }
|
let audioFile = try AVAudioFile(forReading: url)
|
||||||
|
let length = Int(audioFile.length)
|
||||||
|
guard length > 0 else { return nil }
|
||||||
|
|
||||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||||
|
guard let channelData = buffer.floatChannelData else { return nil }
|
||||||
|
|
||||||
|
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
let samplesPerBin = max(1, frameLength / bins)
|
||||||
|
|
||||||
|
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||||
|
for bin in 0..<bins {
|
||||||
|
let start = bin * samplesPerBin
|
||||||
|
let end = min(frameLength, start + samplesPerBin)
|
||||||
|
if start >= end { break }
|
||||||
|
|
||||||
|
var sum: Float = 0
|
||||||
|
var sampleCount = 0
|
||||||
|
for frame in start..<end {
|
||||||
|
var sampleValue: Float = 0
|
||||||
|
for channel in 0..<channelCount {
|
||||||
|
sampleValue += fabsf(channelData[channel][frame])
|
||||||
|
}
|
||||||
|
sum += sampleValue / Float(channelCount)
|
||||||
|
sampleCount += 1
|
||||||
|
}
|
||||||
|
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||||
|
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||||
|
}
|
||||||
|
return magnitudes
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
|
||||||
guard let channelData = buffer.floatChannelData else { return nil }
|
|
||||||
|
|
||||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
|
||||||
let frameLength = Int(buffer.frameLength)
|
|
||||||
let samplesPerBin = max(1, frameLength / bins)
|
|
||||||
|
|
||||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
|
||||||
for bin in 0..<bins {
|
|
||||||
let start = bin * samplesPerBin
|
|
||||||
let end = min(frameLength, start + samplesPerBin)
|
|
||||||
if start >= end { break }
|
|
||||||
|
|
||||||
var sum: Float = 0
|
|
||||||
var sampleCount = 0
|
|
||||||
for frame in start..<end {
|
|
||||||
var sampleValue: Float = 0
|
|
||||||
for channel in 0..<channelCount {
|
|
||||||
sampleValue += fabsf(channelData[channel][frame])
|
|
||||||
}
|
|
||||||
sum += sampleValue / Float(channelCount)
|
|
||||||
sampleCount += 1
|
|
||||||
}
|
|
||||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
|
||||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
|
||||||
}
|
|
||||||
return magnitudes
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user