NavidromeApp/iOS/Views/Visualizer/VisualizerStorageManager.swift
Dallas Groot 00ffd7970e Performance Improvements
Phase 1 — VisualizerStorageManager.swift
VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and
pointsPerFrame. All frame data for a track lives in one contiguous
allocation rather than a [[Float]] array-of-arrays. loadCache now uses
Data(contentsOf:options:.alwaysMapped) — the OS maps the file into
virtual memory and faults pages in on demand rather than reading the
whole file into heap. copyFrame(at:into:) copies a frame slice
directly into _audioLevels using initialize(from:) — no intermediate
[Float] created.
Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift
VisualizerLevelBox now pre-allocates targetLevels, displayLevels,
idleLevels, and the full 16-slot history ring on first use via
resizeIfNeeded. This only triggers when numberOfPoints changes (rare —
settings slider). updateDisplayLevels writes directly into
box.targetLevels throughout — no var targetLevels = [Float](), no map,
no append. The history ring buffer now copies in-place into
pre-allocated slots, eliminating the COW trigger on every frame.
drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas
body no longer falls back to Array(repeating:) since displayLevels is
always pre-allocated. The timer in AudioPlayer now calls
buf.copyFrame(at:into:&_audioLevels) directly — no intermediate
[Float] copy.
Phase 3 — View invalidation + drawBars fix
fillColors hoisted out of the drawBars per-bar loop — it was
allocating a new [Color] array count times per frame (8–24 allocations
per draw call at 60fps). Now computed once before the loop.
@ObservedObject var settings and @StateObject private var box are
correct — box has zero @Published properties so it never triggers
parent redraws. The Canvas closure only captures tickDate which
changes every tick, ensuring per-tick re-execution without touching
SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00

135 lines
5.6 KiB
Swift

import Foundation
// MARK: - VisFrameBuffer
/// Flat, memory-efficient storage for pre-computed visualizer FFT frames.
/// All frames packed into a single ContiguousArray<Float> in row-major order:
/// [frame0pt0, frame0pt1, ..., frame1pt0, frame1pt1, ...]
/// This eliminates the [[Float]] array-of-arrays structure and allows
/// O(1) index computation with zero allocation for frame access.
struct VisFrameBuffer {
let data: ContiguousArray<Float>
let frameCount: Int
let pointsPerFrame: Int
static let empty = VisFrameBuffer(data: [], frameCount: 0, pointsPerFrame: 0)
var isEmpty: Bool { frameCount == 0 }
/// Copy frame at `index` into a pre-allocated destination buffer.
/// No intermediate allocation copies directly from flat storage.
func copyFrame(at index: Int, into dest: inout [Float]) {
guard index >= 0 && index < frameCount else { return }
let start = index * pointsPerFrame
let end = start + pointsPerFrame
guard end <= data.count else { return }
// Resize destination only if count changed (rare: settings change)
if dest.count != pointsPerFrame {
dest = [Float](repeating: 0, count: pointsPerFrame)
}
data.withUnsafeBufferPointer { src in
dest.withUnsafeMutableBufferPointer { dst in
dst.baseAddress!.initialize(
from: src.baseAddress!.advanced(by: start),
count: pointsPerFrame
)
}
}
}
}
/// Caches pre-computed visualizer FFT frames to disk for instant playback
actor VisualizerStorageManager {
static let shared = VisualizerStorageManager()
private let fileManager = FileManager.default
private var cacheDir: URL {
let dir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("VisualizerCache", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
private func fileURL(for trackId: String) -> URL {
cacheDir.appendingPathComponent("mitsuha_\(trackId).bin")
}
func hasCache(for trackId: String) -> Bool {
fileManager.fileExists(atPath: fileURL(for: trackId).path)
}
/// Save frames in flat binary format:
/// [frameCount: UInt32][pointsPerFrame: UInt32][Float data...]
func saveCache(frames: [[Float]], for trackId: String) throws {
let frameCount = frames.count
let pointsPerFrame = frames.first?.count ?? 0
guard frameCount > 0, pointsPerFrame > 0 else { return }
var data = Data(capacity: 8 + frameCount * pointsPerFrame * MemoryLayout<Float>.size)
var fc = UInt32(frameCount)
var ppf = UInt32(pointsPerFrame)
data.append(Data(bytes: &fc, count: 4))
data.append(Data(bytes: &ppf, count: 4))
for frame in frames {
frame.withUnsafeBytes { data.append(contentsOf: $0) }
}
try data.write(to: fileURL(for: trackId), options: .atomic)
}
/// Load frames as a flat VisFrameBuffer using memory-mapped I/O.
/// No heap allocation for frame data the OS maps file pages on demand.
func loadCache(for trackId: String) throws -> VisFrameBuffer {
// .alwaysMapped: OS memory-maps the file rather than reading into heap.
// Pages are faulted in on first access, never fully copied.
let raw = try Data(contentsOf: fileURL(for: trackId), options: .alwaysMapped)
guard raw.count >= 8 else { return .empty }
let frameCount = Int(raw.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) })
let pointsPerFrame = Int(raw.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) })
let floatSize = MemoryLayout<Float>.size
let expectedSize = 8 + frameCount * pointsPerFrame * floatSize
guard raw.count >= expectedSize, frameCount > 0, pointsPerFrame > 0 else { return .empty }
// Copy mapped region into a single flat ContiguousArray<Float>.
// One allocation for the entire track's data never per-frame.
let totalFloats = frameCount * pointsPerFrame
var flat = ContiguousArray<Float>(repeating: 0, count: totalFloats)
flat.withUnsafeMutableBufferPointer { dst in
raw.withUnsafeBytes { src in
dst.baseAddress!.initialize(
from: src.baseAddress!.advanced(by: 8).assumingMemoryBound(to: Float.self),
count: totalFloats
)
}
}
return VisFrameBuffer(data: flat, frameCount: frameCount, pointsPerFrame: pointsPerFrame)
}
// MARK: - Cache Management
func cacheSize() -> Int64 {
guard let files = try? fileManager.contentsOfDirectory(
at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]
) else { return 0 }
return files.reduce(0) { sum, url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
return sum + Int64(size)
}
}
func cachedTrackCount() -> Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil).count) ?? 0
}
func clearCache() {
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
func removeCache(for trackId: String) {
try? fileManager.removeItem(at: fileURL(for: trackId))
}
}