Fixed idle visualizer bug

AudioPlayer.swift — startRadioSimulation() replaces
startLevelSimulation() for radio streams. It generates two overlapping
sine waves with a slow breathing amplitude envelope. The key property:
it’s driven by an internal radioPhase counter that increments at a
fixed rate, completely independent of currentTime. Buffer seeks, HLS
restarts, and stream reconnects have zero visual effect.
MitsuhaVisualizerView.swift — Two fixes:
	1.	TimelineView(.periodic(from: .distantPast, by: tickInterval)) —
using .distantPast as the stable schedule origin instead of .now.
Previously, any recalculation of tickInterval (triggered by
isRenderingActive flipping during a radio buffer hiccup) would reset
the origin to the current moment, deferring the next tick by a full
interval — the visible stall.
	2.	songId parameter with .onChange(of: songId) that resets
peakFollower, levelHistoryBuf, and lastTickTime on song change. This
prevents the outgoing song’s simulation spike from “raising” the wave
at the start of the next song.
NowPlayingView.swift and MainTabView.swift — Pass songId:
audioPlayer.currentSong?.id through to MitsuhaVisualizerView and
CompactVisualizerView at all call sites.
This commit is contained in:
Dallas Groot 2026-04-11 19:01:10 -07:00
parent 7d78b22ce2
commit 282eb5d80c
4 changed files with 74 additions and 5 deletions

View file

@ -796,7 +796,18 @@ class AudioPlayer: NSObject, ObservableObject {
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
// Radio streams use a smooth sinusoidal simulation random phase
// simulation reacts to currentTime jumps (buffer seeks) causing
// the peakFollower to spike and "raise" the wave unexpectedly.
#if os(iOS)
if isRadioStream {
startRadioSimulation()
} else {
startLevelSimulation()
}
#else
startLevelSimulation()
#endif
}
// MARK: - AVAudioEngine Path (local files with real FFT)
@ -1607,7 +1618,48 @@ class AudioPlayer: NSObject, ObservableObject {
#endif
// MARK: - Simulated Level Animation (for streams)
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
/// Radio streams can't be pre-analyzed and their AVPlayer currentTime can
/// jump on buffer seeks using the same random-phase simulation as music
/// causes the peakFollower in the visualizer to spike and "raise" the wave.
/// This uses a time-based sine wave that's completely independent of
/// currentTime, so buffer resets and seek-backs have no visual effect.
private func startRadioSimulation() {
stopLevelTimer()
var radioPhase: Double = 0
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
#if os(iOS)
guard VisualizerSettings.shared.enabled else { return }
#endif
guard self.isPlaying else {
if self.internalLevels.contains(where: { $0 > 0.01 }) {
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
self.setLevels(self.internalLevels)
}
return
}
radioPhase += 1.0 / 30.0
// Smooth sine wave across the frequency bands gentle, never spikes.
// Base amplitude 0.35, modulated by a slow breathing envelope (0.15 range)
// so it looks alive without jarring jumps.
let count = self.internalLevels.count
let breathe = Float(0.35 + 0.15 * sin(radioPhase * 0.4))
for i in 0..<count {
let x = Float(i) / Float(max(count - 1, 1))
// Two overlapping sine waves at different frequencies for organic shape
let wave1 = sin(Float(radioPhase * 1.1) + x * .pi * 2.0)
let wave2 = sin(Float(radioPhase * 0.7) + x * .pi * 3.5) * 0.4
self.internalLevels[i] = breathe * (0.5 + 0.5 * (wave1 + wave2) / 1.4)
}
self.setLevels(self.internalLevels)
}
}
private func startLevelSimulation() {
stopLevelTimer()
lastTargetPhase = -1
@ -1732,4 +1784,3 @@ class AudioPlayer: NSObject, ObservableObject {
stop()
}
}

View file

@ -600,6 +600,7 @@ struct MiniPlayerBar: View {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
isSongLoaded: audioPlayer.currentSong != nil,
songId: audioPlayer.currentSong?.id,
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
height: VisualizerSettings.shared.miniPlayerHeight
)
@ -756,6 +757,7 @@ struct DynamicIslandView: View {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
isSongLoaded: audioPlayer.currentSong != nil,
songId: audioPlayer.currentSong?.id,
accentColor: themeColor,
height: 44
)

View file

@ -428,6 +428,7 @@ struct NowPlayingView: View {
MitsuhaVisualizerView(
isPlaying: audioPlayer.isPlaying,
isSongLoaded: audioPlayer.currentSong != nil,
songId: audioPlayer.currentSong?.id,
accentColor: accentPink,
isVisible: isPresented
)

View file

@ -285,6 +285,7 @@ struct MitsuhaVisualizerView: View {
var previewLevels: [Float]? = nil
let isPlaying: Bool
var isSongLoaded: Bool = true
var songId: String? = nil // used to reset peakFollower on song change
let accentColor: Color
var compact: Bool = false
var isVisible: Bool = true
@ -320,7 +321,11 @@ struct MitsuhaVisualizerView: View {
let targetFPS = isRenderingActive ? settings.effectiveFPS : 2.0
let tickInterval = 1.0 / max(targetFPS, 1.0)
TimelineView(.periodic(from: .now, by: tickInterval)) { timeline in
// Use a stable start date so the periodic schedule origin never
// resets. Previously, .now was passed every time targetFPS changed
// (e.g. on a radio buffer hiccup), causing the next tick to be
// deferred by a full interval the visible "stall" in the wave.
TimelineView(.periodic(from: .distantPast, by: tickInterval)) { timeline in
let tickDate = timeline.date
Canvas { context, size in
_ = tickDate
@ -374,13 +379,13 @@ struct MitsuhaVisualizerView: View {
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
box.peakFollower = 0.01
box.lastTickTime = 0 // reset so first resume frame uses 1/60 fallback dt
box.lastTickTime = 0
DebugLogger.shared.log(
"PAUSE → levelHistoryBuf cleared, lastTickTime reset " +
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count)]",
category: "VisDebug", level: .info)
} else {
box.resumeTickCount = 0 // reset so we log the first 3 ticks
box.resumeTickCount = 0
DebugLogger.shared.log(
"RESUME → isRenderingActive=\(isRenderingActive) " +
"isAppActive=\(isAppActive) isVisible=\(isVisible) " +
@ -389,6 +394,14 @@ struct MitsuhaVisualizerView: View {
category: "VisDebug", level: .info)
}
}
// Reset peakFollower on song change so a spike from the outgoing
// simulation doesn't "raise" the wave at the start of the next song.
.onChange(of: songId) { _, _ in
box.peakFollower = 0.01
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
box.lastTickTime = 0
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
isAppActive = true
box.lastTickTime = 0
@ -871,6 +884,7 @@ struct MitsuhaVisualizerView: View {
struct CompactVisualizerView: View {
let isPlaying: Bool
var isSongLoaded: Bool = true
var songId: String? = nil
let accentColor: Color
let height: CGFloat
@ -878,6 +892,7 @@ struct CompactVisualizerView: View {
MitsuhaVisualizerView(
isPlaying: isPlaying,
isSongLoaded: isSongLoaded,
songId: songId,
accentColor: accentColor,
compact: true
)