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:
parent
fde3df0d26
commit
00ffd7970e
2 changed files with 185 additions and 128 deletions
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue