mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so platform-specific code is never touched), with test targets indexed and the share extension built. 277 dead declarations removed or demoted: dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed- feature remnants (autocomplete command suggestions, back-swipe tuning, MediaSendError, GeohashParticipantTracker), unused Tor dormancy bindings, assign-only properties, unused parameters (renamed to _), and redundant public accessibility. 13 orphaned localization keys deleted across all 29 locales (old pre-#1392 location-notes UI, app_info warnings). Two real tests were flagged as unused because they never ran: Swift Testing methods missing @Test (NostrProtocolTests. testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests. testAssemblesCompressedLargeFrame). Re-armed both; they pass. Deliberately kept, now recorded in .periphery.baseline.json: iOS-only code invisible to the CI macOS scan, C FFI signatures, keep-alive NWPathMonitor reference, InboundEventKey.eventID (dedup semantics), wifiBulk capability bit (reserved for Wi-Fi bulk work, used by BitFoundation package tests), and the String secureClear cluster (exercised by package tests). New: .periphery.yml config and an advisory Dead Code CI job (mirrors the SwiftLint precedent from #1361) that fails on findings not in the committed baseline. Verified: full macOS app suite, BitFoundation (119) and BitLogger (13) package tests green; periphery scan --strict exits clean. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
108 lines
4.2 KiB
Swift
108 lines
4.2 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import BitLogger
|
|
|
|
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
|
final class WaveformCache {
|
|
static let shared = WaveformCache()
|
|
|
|
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
|
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
|
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
|
|
|
private init() {}
|
|
|
|
func cachedWaveform(for url: URL) -> [Float]? {
|
|
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) {
|
|
queue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Check cache (read-only, no update needed on cache hit for performance)
|
|
if let entry = self.cache[url] {
|
|
DispatchQueue.main.async { completion(entry.waveform) }
|
|
return
|
|
}
|
|
|
|
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
|
DispatchQueue.main.async { completion([]) }
|
|
return
|
|
}
|
|
|
|
self.queue.async(flags: .barrier) { [weak self] in
|
|
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) }
|
|
}
|
|
}
|
|
|
|
func purge(url: URL) {
|
|
queue.async(flags: .barrier) { [weak self] in
|
|
self?.cache.removeValue(forKey: url)
|
|
}
|
|
}
|
|
|
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
|
guard bins > 0 else { return nil }
|
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
|
return autoreleasepool {
|
|
do {
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|