more fixes

This commit is contained in:
Dallas Groot 2026-04-05 12:05:20 -07:00
parent bbb7faf488
commit 75a4343ad1
2 changed files with 56 additions and 69 deletions

View file

@ -28,20 +28,18 @@ class AudioPlayer: NSObject, ObservableObject {
@Published var volume: Float = 0.8
@Published var repeatMode: RepeatMode = .off
@Published var shuffleEnabled = false
// audioLevels is NOT @Published avoids 60fps SwiftUI state thrashing
// Visualizer pulls directly via AudioPlayer.shared.currentLevels()
// No lock needed: writes happen on main thread (Timer callbacks),
// reads happen from view body. A one-frame-stale read is imperceptible.
// audioLevels is NOT @Published avoids 60fps SwiftUI state thrashing.
// Instead, levelTick increments every time levels change. The visualizer
// observes levelTick: when it changes, body re-evaluates, Canvas re-executes,
// and pulls the latest levels via currentLevels(). Guaranteed dependency.
@Published private(set) var levelTick: Int = 0
nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30)
/// Current visualizer levels (called from TimelineView body)
func currentLevels() -> [Float] {
_audioLevels
}
/// Write visualizer levels (called from timers/taps on main thread)
func currentLevels() -> [Float] { _audioLevels }
private func setLevels(_ levels: [Float]) {
_audioLevels = levels
levelTick &+= 1
}
@Published var isUsingRealFFT = false
@ -724,11 +722,10 @@ class AudioPlayer: NSObject, ObservableObject {
isPlaying = false
#if os(iOS)
stopOfflineVisTimer()
stopLevelTimer()
// DO NOT zero _audioLevels here. The Canvas reads zeros when isPlaying=false,
// so the wave decays visually via viscosity smoothing regardless.
// Preserving _audioLevels means on resume the Canvas immediately has real data
// the wave reacts on the very first frame instead of waiting for the timer's first tick.
// Don't stop levelTimer the simulation timer's !isPlaying branch decays
// internalLevels to zero and calls setLevels(zeros), which increments levelTick
// so the Canvas keeps re-rendering and the wave visually decays during pause.
// For offline vis path the offlineVisTimer (restarted above) handles this.
internalLevels = Array(repeating: 0, count: 30)
#endif
updateNowPlayingInfo()
@ -750,20 +747,35 @@ class AudioPlayer: NSObject, ObservableObject {
}
isPlaying = true
#if os(iOS)
// Prime levels immediately before any timer fires.
// The CADisplayLink draws at 60fps; the level timers fire at 30fps.
// Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels
// (zeroed by pause) and the wave starts flat then slowly ramps up.
if isUsingOfflineVis {
// Offline vis: prime with the last-rendered frame at current position
if !offlineVisFrames.isEmpty {
let dur = duration
let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0
let idx = min(Int(pct * Double(offlineVisFrames.count)), offlineVisFrames.count - 1)
setLevels(offlineVisFrames[idx])
}
startOfflineVisSync()
} else if isUsingRealFFT, !isUsingOfflineVis {
// Engine (real FFT) path restart the raw FFT publish timer.
// stopLevelTimer() on pause killed this; startLevelSimulation() would
// push random simulation data instead of the real FFT tap output.
// Engine FFT path: rawFFTLevels still holds pre-pause data push it immediately
setLevels(rawFFTLevels)
stopLevelTimer()
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.setLevels(self.rawFFTLevels)
}
} else {
// AVPlayer / streaming / radio use level simulation
startLevelSimulation()
// Simulation path: seed internalLevels from a fresh random target
// so the wave has height immediately rather than starting from floor.
lastTargetPhase = -1
targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) }
internalLevels = targetLevels.map { $0 * 0.8 }
setLevels(internalLevels)
if levelTimer == nil { startLevelSimulation() }
}
#endif
updateNowPlayingInfo()
@ -1289,7 +1301,14 @@ class AudioPlayer: NSObject, ObservableObject {
stopOfflineVisTimer()
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
guard let self = self, self.isUsingOfflineVis, self.isPlaying else { return }
guard let self = self, self.isUsingOfflineVis else { return }
guard self.isPlaying else {
// Keep pulsing zeros so levelTick increments and the visualizer can decay
if self._audioLevels.contains(where: { $0 > 0.005 }) {
self.setLevels(Array(repeating: 0, count: 30))
}
return
}
let frames = self.offlineVisFrames
guard !frames.isEmpty else { return }

View file

@ -140,39 +140,6 @@ final class WaveStateCache {
private init() {}
}
// MARK: - CADisplayLink Refresh Driver
/// Drives Canvas redraws at the screen's native refresh rate via CADisplayLink.
/// @Published tick increments each frame SwiftUI re-renders MitsuhaVisualizerView.body
/// Canvas drawing closure executes wave updates.
/// CADisplayLink runs on the main run loop in .common mode: fires during scrolls,
/// sheet presentations, pause state, and any other condition that stalls SwiftUI's
/// animation scheduler.
fileprivate final class VisualRefreshDriver: ObservableObject {
@Published private(set) var tick: Int = 0
private var displayLink: CADisplayLink?
func start(fps: Double) {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(step))
link.preferredFrameRateRange = CAFrameRateRange(
minimum: Float(min(fps, 30)),
maximum: Float(min(fps, 60)),
preferred: Float(min(fps, 60))
)
link.add(to: .main, forMode: .common)
displayLink = link
}
func stop() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func step() {
tick &+= 1 // wrapping increment never overflows
}
}
// MARK: - Level Box
/// Per-instance mutable render state. Zero @Published properties no observation warnings.
fileprivate final class VisualizerLevelBox: ObservableObject {
@ -194,27 +161,32 @@ struct MitsuhaVisualizerView: View {
@ObservedObject var settings = VisualizerSettings.shared
@ObservedObject var albumColors = AlbumColorExtractor.shared
// Observing AudioPlayer directly: levelTick is @Published and increments every time
// setLevels() is called i.e. every time real audio data arrives. SwiftUI registers
// a definitive dependency and re-evaluates body on every level update. No separate
// timer, no CADisplayLink, no race between rendering and data delivery.
@ObservedObject private var audioPlayer = AudioPlayer.shared
@StateObject private var driver = VisualRefreshDriver()
@StateObject private var box = VisualizerLevelBox()
@StateObject private var box = VisualizerLevelBox()
private static let historySize = 16
var body: some View {
Group {
if settings.enabled {
// Reading driver.tick as a local binding forces SwiftUI to register
// a real @Published dependency. The let _ = pattern can be elided by
// the compiler in ViewBuilder context; assigning to a named local is not.
let currentTick = driver.tick
// audioPlayer.levelTick is @Published and changes every time setLevels()
// is called in AudioPlayer i.e. on every actual audio data delivery.
// Reading it here gives SwiftUI a concrete dependency: body re-evaluates
// exactly when new level data arrives, Canvas re-executes, wave updates.
// When paused the timers stop so levelTick stops changing Canvas holds
// last frame (correct). On resume the timers restart and levelTick resumes
// incrementing Canvas immediately reacts.
let _ = audioPlayer.levelTick
Canvas { context, size in
// Reference currentTick so the Canvas closure captures it
// a new value each frame guarantees SwiftUI re-executes the drawing closure.
_ = currentTick
let now = CACurrentMediaTime()
let rawLevels: [Float] = isPlaying
? (previewLevels ?? AudioPlayer.shared.currentLevels())
? (previewLevels ?? audioPlayer.currentLevels())
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
updateDisplayLevels(newRawLevels: rawLevels)
updateWobblePhase(now: now)
@ -236,10 +208,6 @@ struct MitsuhaVisualizerView: View {
box.displayLevels = cached.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: cached
driver.start(fps: settings.effectiveFPS)
}
.onDisappear {
driver.stop()
}
}
}