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.
This commit is contained in:
Dallas Groot 2026-04-10 16:25:49 -07:00
parent fde3df0d26
commit 00ffd7970e
2 changed files with 185 additions and 128 deletions

View file

@ -143,13 +143,34 @@ final class WaveStateCache {
// MARK: - Level Box
/// Per-instance mutable render state. Zero @Published properties no observation warnings.
/// All working buffers pre-allocated here so the 60fps render loop does zero heap allocation.
fileprivate final class VisualizerLevelBox: ObservableObject {
var displayLevels: [Float] = []
var peakFollower: Float = 0.01
var displayLevels: [Float] = []
var targetLevels: [Float] = [] // scratch buffer pre-allocated, mutated in-place each frame
var idleLevels: [Float] = [] // flat idle wave pre-allocated, updated only when count changes
var peakFollower: Float = 0.01
var levelHistoryBuf: [[Float]] = []
var historyWriteIdx: Int = 0
var wobblePhaseOffset: Double = 0
var lastTickTime: CFTimeInterval = 0
var lastTickTime: CFTimeInterval = 0
/// Resize all per-frame buffers when point count changes (rare settings slider).
func resizeIfNeeded(count: Int, idleAmplitude: Float) {
guard count > 0 else { return }
if targetLevels.count != count {
targetLevels = [Float](repeating: 0, count: count)
displayLevels = [Float](repeating: idleAmplitude, count: count)
idleLevels = [Float](repeating: idleAmplitude, count: count)
// Pre-fill history ring with idle so depth ghost doesn't start blank
levelHistoryBuf = [[Float]](
repeating: [Float](repeating: idleAmplitude, count: count),
count: MitsuhaVisualizerView.historySize
)
historyWriteIdx = 0
} else if idleLevels.first != idleAmplitude {
for i in 0..<count { idleLevels[i] = idleAmplitude }
}
}
}
// MARK: - Main Visualizer View
@ -171,7 +192,7 @@ struct MitsuhaVisualizerView: View {
/// Whether the app is currently in the foreground.
@State private var isAppActive = true
private static let historySize = 16
static let historySize = 16
/// True only when it's worth doing FFT + Canvas work.
/// Any one of these being false draw idle state instead.
@ -187,10 +208,14 @@ struct MitsuhaVisualizerView: View {
if settings.enabled {
let tickDate = timeline.date // captured by Canvas forces re-execution
Canvas { context, size in
_ = tickDate // reference keeps the capture alive
_ = tickDate
// Resize scratch buffers if point count changed (rare settings slider).
// This is the only allocation allowed in this path, and only when count changes.
box.resizeIfNeeded(count: settings.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
guard isRenderingActive else {
// App backgrounded or view hidden draw idle wave, skip FFT
drawIdleState(ctx: context, size: size)
return
}
@ -200,30 +225,33 @@ struct MitsuhaVisualizerView: View {
updateDisplayLevels(newRawLevels: rawLevels, t: t)
updateWobblePhase(t: t)
let pts = box.displayLevels.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: box.displayLevels
guard pts.count >= 2 else { return }
guard box.displayLevels.count >= 2 else { return }
switch settings.style {
case .wave: drawWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
case .bar: drawBars(ctx: context, size: size, levels: pts)
case .line: drawLine(ctx: context, size: size, levels: pts)
case .siriWave: drawSiriWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels)
case .line: drawLine(ctx: context, size: size, levels: box.displayLevels)
case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
}
}
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
.animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying)
.onAppear {
let cached = compact ? WaveStateCache.shared.compactLevels : []
box.displayLevels = cached.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: cached
box.resizeIfNeeded(count: settings.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
// Seed compact vis from shared cache for morph continuity
if compact, !WaveStateCache.shared.compactLevels.isEmpty {
let cached = WaveStateCache.shared.compactLevels
let count = box.displayLevels.count
for i in 0..<min(cached.count, count) {
box.displayLevels[i] = cached[i]
}
}
}
.onChange(of: isPlaying) { _, playing in
if !playing {
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
box.peakFollower = 0.01
box.peakFollower = 0.01
}
}
// App foregrounded reset lastTickTime so the first dt isn't huge
@ -244,12 +272,12 @@ struct MitsuhaVisualizerView: View {
// MARK: - Idle State
private func drawIdleState(ctx: GraphicsContext, size: CGSize) {
let pts = Array(repeating: Float(settings.idleAmplitude), count: max(settings.numberOfPoints, 2))
guard box.idleLevels.count >= 2 else { return }
switch settings.style {
case .wave: drawWave(ctx: ctx, size: size, levels: pts, continuousTime: 0)
case .bar: drawBars(ctx: ctx, size: size, levels: pts)
case .line: drawLine(ctx: ctx, size: size, levels: pts)
case .siriWave: drawSiriWave(ctx: ctx, size: size, levels: pts, continuousTime: 0)
case .wave: drawWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
case .bar: drawBars(ctx: ctx, size: size, levels: box.idleLevels)
case .line: drawLine(ctx: ctx, size: size, levels: box.idleLevels)
case .siriWave: drawSiriWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
}
}
@ -268,98 +296,90 @@ struct MitsuhaVisualizerView: View {
@discardableResult
private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool {
let count = settings.numberOfPoints
guard count > 0, !newRawLevels.isEmpty else { return false }
// Delta time from the same CACurrentMediaTime clock as updateWobblePhase.
// lastTickTime is 0 on first frame fall back to 60fps assumption.
let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0)
guard count > 0, !newRawLevels.isEmpty,
box.targetLevels.count == count else { return false }
let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0)
let sens = Float(settings.sensitivity)
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
var targetLevels = [Float]()
targetLevels.reserveCapacity(count)
// Write directly into pre-allocated box.targetLevels
if isPreProcessed {
// Offline vis frames are already normalized (peak 0.8) and log-binned.
// Only apply sensitivity for user control no baseMultiplier needed.
if newRawLevels.count == count {
targetLevels = newRawLevels.map { min(1.0, $0 * sens) }
} else {
// Resample to match display point count via linear interpolation
// Hot path: identical size single in-place pass, zero allocation
for i in 0..<count {
let srcPos = Float(i) / Float(max(count - 1, 1)) * Float(newRawLevels.count - 1)
let lo = Int(srcPos)
let hi = min(lo + 1, newRawLevels.count - 1)
let frac = srcPos - Float(lo)
let val = newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac
targetLevels.append(min(1.0, val * sens))
box.targetLevels[i] = min(1.0, newRawLevels[i] * sens)
}
} else {
// Resample via linear interpolation directly into box.targetLevels
let srcCount = Float(newRawLevels.count - 1)
let dstCount = Float(max(count - 1, 1))
for i in 0..<count {
let srcPos = Float(i) / dstCount * srcCount
let lo = Int(srcPos)
let hi = min(lo + 1, newRawLevels.count - 1)
let frac = srcPos - Float(lo)
box.targetLevels[i] = min(1.0,
(newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac) * sens)
}
}
} else {
// Raw FFT bins full log binning
for _ in 0..<count { targetLevels.append(0) }
// Raw FFT log binning directly into box.targetLevels
let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff)
let mult = Float(settings.baseMultiplier)
for i in 0..<count {
let normalizedIndex = Float(i + 1) / Float(count)
let logIndex = log10(normalizedIndex * 9.0 + 1.0)
let centerBin = logIndex * Float(maxUsefulBin)
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count))
let startBin = max(1, Int(centerBin - binWidth / 2))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth / 2))
let ni = Float(i + 1) / Float(count)
let centerBin = log10(ni * 9.0 + 1.0) * Float(maxUsefulBin)
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count))
let startBin = max(1, Int(centerBin - binWidth * 0.5))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth * 0.5))
var sum: Float = 0
var countInBand = 0
var n = 0
for j in startBin...endBin where j < newRawLevels.count {
sum += newRawLevels[j]
countInBand += 1
sum += newRawLevels[j]; n += 1
}
let averageInBand = countInBand > 0 ? (sum / Float(countInBand)) : 0
targetLevels[i] = min(1.0, averageInBand * Float(settings.baseMultiplier) * sens)
box.targetLevels[i] = min(1.0, (n > 0 ? sum / Float(n) : 0) * mult * sens)
}
}
// Temporal smoothing viscosity 0.17 at 60fps smoothFactor 0.17 per frame
// Dynamic Gain / Peak Follower (in-place on box.targetLevels)
let smoothFactor = min(Float(settings.viscosity) * 60.0 * dt, 1.0)
// Dynamic Gain / Peak Follower
let frameMax = targetLevels.max() ?? 0.0
let frameMax = box.targetLevels.max() ?? 0.0
if settings.dynamicGainEnabled {
if frameMax > box.peakFollower {
// Fast attack catch loud transients in 3 frames
box.peakFollower = box.peakFollower + (frameMax - box.peakFollower) * 0.3
box.peakFollower += (frameMax - box.peakFollower) * 0.3
} else {
// Decay to 0.5 in ~2 seconds fast enough to track quiet passages
let decayPerSec: Float = 0.5
let decayThisFrame = pow(decayPerSec, dt)
box.peakFollower = max(box.peakFollower * decayThisFrame, 0.01)
box.peakFollower = max(box.peakFollower * pow(0.5, dt), 0.01)
}
// Mutate in-place avoids allocating a new array every frame
let normFactor = Float(1.0) / max(box.peakFollower, 0.01)
for i in 0..<targetLevels.count {
targetLevels[i] = min(targetLevels[i] * normFactor, 1.0)
let normFactor = 1.0 / max(box.peakFollower, 0.01)
for i in 0..<count {
box.targetLevels[i] = min(box.targetLevels[i] * normFactor, 1.0)
}
} else {
box.peakFollower = max(box.peakFollower, frameMax)
}
if box.displayLevels.count != count {
box.displayLevels = targetLevels
} else {
for i in 0..<count {
box.displayLevels[i] = box.displayLevels[i] + (targetLevels[i] - box.displayLevels[i]) * smoothFactor
}
// Exponential smooth into displayLevels (in-place)
for i in 0..<count {
box.displayLevels[i] += (box.targetLevels[i] - box.displayLevels[i]) * smoothFactor
}
// Ring Buffer for temporal depth ghost
// Ring buffer: copy in-place into pre-allocated history slot
// Pre-allocated slots mean no COW trigger on assignment.
if box.levelHistoryBuf.count < MitsuhaVisualizerView.historySize {
box.levelHistoryBuf.append(box.displayLevels)
} else {
box.levelHistoryBuf[box.historyWriteIdx] = box.displayLevels
let idx = box.historyWriteIdx
if box.levelHistoryBuf[idx].count == count {
for i in 0..<count {
box.levelHistoryBuf[idx][i] = box.displayLevels[i]
}
} else {
box.levelHistoryBuf[idx] = box.displayLevels
}
box.historyWriteIdx = (box.historyWriteIdx + 1) % MitsuhaVisualizerView.historySize
}
// Cache compact levels so DI morph picks up current wave shape
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
return true
}
@ -553,22 +573,20 @@ struct MitsuhaVisualizerView: View {
let totalGapSpace = gap * CGFloat(count - 1)
let barW = max(1, (w - totalGapSpace) / CGFloat(count))
let isSiri = settings.colorMode == .vibrant
// Hoist fillColors outside loop was allocating [Color] count times per frame
let colors = fillColors
for i in 0..<count {
let amp = CGFloat(levels[i])
let barH = max(compact ? 2 : 4, amp * h * ampScale)
let x = CGFloat(i) * (barW + gap)
let amp = CGFloat(levels[i])
let barH = max(compact ? 2 : 4, amp * h * ampScale)
let x = CGFloat(i) * (barW + gap)
let barTop = baseline - barH
// Bar extends from top to true bottom of screen
let rect = CGRect(x: x, y: barTop, width: barW, height: h - barTop)
let path = Path(roundedRect: rect, cornerRadius: cr)
let rect = CGRect(x: x, y: barTop, width: barW, height: h - barTop)
let path = Path(roundedRect: rect, cornerRadius: cr)
let startPoint = isSiri ? CGPoint(x: 0, y: 0) : CGPoint(x: x, y: barTop)
let endPoint = isSiri ? CGPoint(x: w, y: 0) : CGPoint(x: x, y: h)
let endPoint = isSiri ? CGPoint(x: w, y: 0) : CGPoint(x: x, y: h)
ctx.fill(path, with: .linearGradient(
Gradient(colors: fillColors),
Gradient(colors: colors),
startPoint: startPoint,
endPoint: endPoint
))

View file

@ -1,5 +1,42 @@
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()
@ -22,64 +59,66 @@ actor VisualizerStorageManager {
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 {
// Binary format: [frameCount: UInt32][pointsPerFrame: UInt32][float data...]
let frameCount = frames.count
let frameCount = frames.count
let pointsPerFrame = frames.first?.count ?? 0
guard frameCount > 0, pointsPerFrame > 0 else { return }
var data = Data()
var fc = UInt32(frameCount)
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: &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)
}
func loadCache(for trackId: String) throws -> [[Float]] {
let data = try Data(contentsOf: fileURL(for: trackId))
guard data.count >= 8 else { return [] }
let frameCount = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
let pointsPerFrame = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
let floatSize = MemoryLayout<Float>.size
let expectedSize = 8 + Int(frameCount) * Int(pointsPerFrame) * floatSize
guard data.count >= expectedSize else { return [] }
var frames: [[Float]] = []
frames.reserveCapacity(Int(frameCount))
var offset = 8
for _ in 0..<frameCount {
let frame: [Float] = data.withUnsafeBytes { raw in
let ptr = raw.baseAddress!.advanced(by: offset).assumingMemoryBound(to: Float.self)
return Array(UnsafeBufferPointer(start: ptr, count: Int(pointsPerFrame)))
/// 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
)
}
frames.append(frame)
offset += Int(pointsPerFrame) * floatSize
}
return frames
return VisFrameBuffer(data: flat, frameCount: frameCount, pointsPerFrame: pointsPerFrame)
}
/// Total cache size on disk
// MARK: - Cache Management
func cacheSize() -> Int64 {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]) else {
return 0
}
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)
}
}
/// Number of cached tracks
func cachedTrackCount() -> Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil).count) ?? 0
}