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.
135 lines
5.6 KiB
Swift
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))
|
|
}
|
|
}
|