Finaly fixed Visualizer tweaks to make it more jello-y

This commit is contained in:
Dallas Groot 2026-04-04 23:17:47 -07:00
parent df70a99279
commit f1f98fbf7d
6 changed files with 488 additions and 317 deletions

View file

@ -714,16 +714,20 @@ class AudioPlayer: NSObject, ObservableObject {
SmartCrossfadeManager.shared.pause()
isPlaying = false
stopOfflineVisTimer()
stopLevelTimer()
updateNowPlayingInfo()
return
}
#endif
// Always pause both only one is active, pausing nil is a no-op
player?.pause()
enginePlayerNode?.pause()
isPlaying = false
#if os(iOS)
stopOfflineVisTimer()
stopLevelTimer()
// Zero out levels immediately so the wave decays cleanly via viscosity
setLevels(Array(repeating: 0, count: 30))
internalLevels = Array(repeating: 0, count: 30)
#endif
updateNowPlayingInfo()
}
@ -737,18 +741,18 @@ class AudioPlayer: NSObject, ObservableObject {
return
}
#endif
// Resume whichever player path is active
if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode {
// Engine path (real-time FFT on local file without offline vis)
node.play()
} else {
// AVPlayer path (streams, downloads with offline vis, everything else)
player?.play()
}
isPlaying = true
#if os(iOS)
if isUsingOfflineVis {
startOfflineVisSync()
} else {
// Restart level simulation for streaming / radio / crossfade paths
startLevelSimulation()
}
#endif
updateNowPlayingInfo()
@ -1163,23 +1167,47 @@ class AudioPlayer: NSObject, ObservableObject {
}
if !fetched {
alog("Offline vis: analyzing \(songId)...")
alog("Offline vis: analyzing \(songId) (combined vis+SmartDJ pass)...")
let song = self.currentSong
do {
let frames = try await OfflineAudioAnalyzer.shared.analyze(
// Combined pass: vis frames + SmartDJ profile in one read
// extractSmartDJ only if Companion is disabled or unavailable AND
// SmartDJ crossfade is enabled so we actually need the profile.
let needsSmartDJ = CompanionSettings.shared.smartDJEnabled
&& !CompanionSettings.shared.isEnabled
&& song?.path != nil
&& SmartDJCache.shared.get(song?.path ?? "") == nil
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
url: url,
pointsCount: points,
fps: offlineVisFPS,
cutoff: cutoff
cutoff: cutoff,
extractSmartDJ: needsSmartDJ
) { [weak self] pct in
Task { @MainActor in
self?.offlineVisProgress = pct
}
Task { @MainActor in self?.offlineVisProgress = pct }
}
// Seed SmartDJ cache from on-device analysis if we extracted it
if needsSmartDJ, let path = song?.path {
let profile = SmartDJProfile(
bpm: nil,
silenceStart: result.silenceStart,
silenceEnd: result.silenceEnd,
loudnessLUFS: result.loudnessLUFS
)
SmartDJCache.shared.store(profile, for: path)
alog("Offline vis: seeded on-device SmartDJ profile for \(path) " +
"(LUFS=\(String(format: "%.1f", result.loudnessLUFS ?? 0)), " +
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
"trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s)")
}
let frames = result.visFrames
try? await storage.saveCache(frames: frames, for: songId)
let normalized = Self.normalizeVisFrames(frames)
alog("Offline vis: cached \(frames.count) frames for \(songId)")
await MainActor.run {
self.offlineVisFrames = normalized
self.isUsingOfflineVis = true
@ -1189,9 +1217,7 @@ class AudioPlayer: NSObject, ObservableObject {
}
} catch {
alog("Offline vis: analysis failed: \(error.localizedDescription)")
await MainActor.run {
self.startLevelSimulation()
}
await MainActor.run { self.startLevelSimulation() }
}
} // end if !fetched
}

View file

@ -94,97 +94,80 @@ struct CompanionSettingsView: View {
} footer: {
Text("The Companion API provides advanced features: file uploads, ID3 tag editing, and Smart DJ audio analysis.")
}
// Smart DJ
if settings.isEnabled {
Section {
Toggle("Smart DJ", isOn: $settings.smartDJEnabled)
// Smart DJ available with or without Companion API
Section {
Toggle("Smart DJ", isOn: $settings.smartDJEnabled)
.tint(accentPink)
if settings.smartDJEnabled {
Toggle("Smart Crossfade", isOn: $crossfade.isEnabled)
.tint(accentPink)
if settings.smartDJEnabled {
Toggle("Smart Crossfade", isOn: $crossfade.isEnabled)
.tint(accentPink)
if crossfade.isEnabled {
HStack {
Text("Crossfade")
.foregroundColor(.gray)
Spacer()
Text("\(String(format: "%.0f", crossfade.crossfadeDuration))s")
.foregroundColor(.white)
.frame(width: 30)
Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 1)
.tint(accentPink)
.frame(width: 140)
}
HStack {
Text("Target LUFS")
.foregroundColor(.gray)
Spacer()
Text("\(String(format: "%.0f", crossfade.targetLUFS))")
.foregroundColor(.white)
.frame(width: 30)
Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1)
.tint(accentPink)
.frame(width: 140)
}
Toggle("Skip Silence", isOn: $crossfade.skipSilence)
if crossfade.isEnabled {
HStack {
Text("Crossfade")
.foregroundColor(.gray)
Spacer()
Text("\(String(format: "%.0f", crossfade.crossfadeDuration))s")
.foregroundColor(.white)
.frame(width: 30)
Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 1)
.tint(accentPink)
.frame(width: 140)
}
HStack {
Text("Target LUFS")
.foregroundColor(.gray)
Spacer()
Text("\(String(format: "%.0f", crossfade.targetLUFS))")
.foregroundColor(.white)
.frame(width: 30)
Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1)
.tint(accentPink)
.frame(width: 140)
}
Toggle("Skip Silence", isOn: $crossfade.skipSilence)
.tint(accentPink)
}
} header: {
Text("Smart DJ")
} footer: {
Text("Smart DJ analyzes tracks for BPM, silence boundaries, and loudness. Crossfade uses this data for seamless transitions and volume normalization.")
}
// Upload
} header: { Text("Smart DJ") } footer: {
Text(settings.isEnabled
? "Uses server-side BPM, silence, and loudness analysis for seamless crossfades."
: "On-device analysis (no Companion API needed): silence detection and loudness for downloaded songs. BPM requires the API.")
}
if settings.isEnabled {
Section {
NavigationLink(destination: BatchUploadView()) {
HStack {
Image(systemName: "icloud.and.arrow.up")
.foregroundColor(accentPink)
Image(systemName: "icloud.and.arrow.up").foregroundColor(accentPink)
Text("Upload Music")
}
}
} header: {
Text("Upload")
} footer: {
} header: { Text("Upload") } footer: {
Text("Import a .zip archive of audio files, batch-tag them, and upload to your server via the Companion API.")
}
// Server Actions
Section {
Button(action: triggerScan) {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(accentPink)
Text("Trigger Navidrome Scan")
.foregroundColor(.white)
Image(systemName: "arrow.triangle.2.circlepath").foregroundColor(accentPink)
Text("Trigger Navidrome Scan").foregroundColor(.white)
}
}
HStack {
Text("Smart DJ Profiles Cached")
.foregroundColor(.gray)
Text("Smart DJ Profiles Cached").foregroundColor(.gray)
Spacer()
Text("\(SmartDJCache.shared.cachedCount)")
.foregroundColor(.white)
Text("\(SmartDJCache.shared.cachedCount)").foregroundColor(.white)
}
.font(.system(size: 14))
Button(role: .destructive, action: {
SmartDJCache.shared.clearAll()
}) {
Text("Clear Local DJ Cache")
Button(action: { SmartDJCache.shared.clearAll() }) {
Text("Clear Profile Cache").foregroundColor(.red)
}
} header: {
Text("Server Actions")
}
// Analysis (runs on the Pi)
} header: { Text("Server Actions") }
}
if settings.smartDJEnabled {
Section {
Button(action: { triggerPreAnalyze(force: false) }) {
HStack {
@ -192,11 +175,11 @@ struct CompanionSettingsView: View {
.foregroundColor(accentPink)
.symbolEffect(.pulse, isActive: analyzeStatus == .analyzing)
VStack(alignment: .leading, spacing: 2) {
Text("Pre-Analyze Missing Songs")
.foregroundColor(.white)
Text("Analyze only songs without a Smart DJ profile")
.font(.system(size: 11))
.foregroundColor(.gray)
Text("Pre-Analyze Missing Songs").foregroundColor(.white)
Text(settings.isEnabled
? "Server + on-device for songs without a profile"
: "On-device analysis for downloaded songs only")
.font(.system(size: 11)).foregroundColor(.gray)
}
}
}
@ -204,33 +187,29 @@ struct CompanionSettingsView: View {
Button(action: { triggerPreAnalyze(force: true) }) {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.orange)
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Re-Analyze All Songs")
.foregroundColor(.white)
Text("Force re-analyze every track (slow on large libraries)")
.font(.system(size: 11))
.foregroundColor(.gray)
Text("Re-Analyze All Songs").foregroundColor(.white)
Text("Clear cache and re-analyze every downloaded track")
.font(.system(size: 11)).foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
Button(action: triggerVisPrecompute) {
HStack {
Image(systemName: "waveform.path")
.foregroundColor(accentPink)
VStack(alignment: .leading, spacing: 2) {
Text("Pre-Compute Visualizer Frames")
.foregroundColor(.white)
Text("Generate Mitsuha FFT frames on the server")
.font(.system(size: 11))
.foregroundColor(.gray)
if settings.isEnabled {
Button(action: triggerVisPrecompute) {
HStack {
Image(systemName: "waveform.path").foregroundColor(accentPink)
VStack(alignment: .leading, spacing: 2) {
Text("Pre-Compute Visualizer Frames").foregroundColor(.white)
Text("Generate Mitsuha FFT frames on the server")
.font(.system(size: 11)).foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
}
.disabled(analyzeStatus == .analyzing)
if let msg = analyzeMessage {
Text(msg)
@ -238,9 +217,11 @@ struct CompanionSettingsView: View {
.foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray)
}
} header: {
Text("Server Analysis (runs on Pi)")
Text(settings.isEnabled ? "Analysis (Server + On-Device)" : "Analysis (On-Device)")
} footer: {
Text("These commands run on your Companion API server. Large libraries may take a while.")
Text(settings.isEnabled
? "Server analysis runs on the Pi. On-device analysis handles downloaded songs. Both feed the same Smart DJ cache."
: "Scans downloaded songs for silence boundaries and approximate loudness. Cached locally for crossfade use.")
}
}
}
@ -261,61 +242,76 @@ struct CompanionSettingsView: View {
}
}
// MARK: - Pre-Analyze
// MARK: - Pre-Analyze (server + on-device combined)
private func triggerPreAnalyze(force: Bool) {
analyzeStatus = .analyzing
analyzeMessage = force ? "Re-analyzing all songs on server..." : "Analyzing missing songs on server..."
analyzeMessage = "Starting analysis..."
Task {
do {
// Trigger server-side precompute via the visualizer/precompute endpoint
if force {
// For force re-analyze, we hit the precompute endpoint
// which processes all un-cached files
var serverMsg: String? = nil
var onDeviceCount = 0
var onDeviceErrors = 0
// 1. Server-side analysis
if CompanionSettings.shared.isEnabled {
do {
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
req.httpMethod = "POST"
req.timeoutInterval = 10
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
await MainActor.run {
analyzeStatus = .done
analyzeMessage = "\(msg)"
}
} else {
// Pre-analyze missing: same endpoint, server skips already-analyzed
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
req.httpMethod = "POST"
req.timeoutInterval = 10
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
await MainActor.run {
analyzeStatus = .done
analyzeMessage = "\(msg)"
}
}
} catch {
await MainActor.run {
analyzeStatus = .failed
analyzeMessage = "\(error.localizedDescription)"
serverMsg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Server analysis started"
} catch {
serverMsg = "Server unavailable — on-device only"
}
}
// Reset after 5s
try? await Task.sleep(for: .seconds(5))
await MainActor.run {
if analyzeStatus != .analyzing {
analyzeStatus = .idle
analyzeMessage = nil
// 2. On-device analysis for downloaded songs
let offlineSongs = await MainActor.run { OfflineManager.shared.downloadedSongs }
let total = offlineSongs.count
for (idx, downloaded) in offlineSongs.enumerated() {
let song = downloaded.song
guard let path = song.path else { continue }
if !force && SmartDJCache.shared.get(path) != nil { continue }
guard let localURL = OfflineManager.shared.localURL(for: song.id) else { continue }
await MainActor.run {
analyzeMessage = "Analyzing \(idx + 1)/\(total): \(song.title)"
}
do {
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
url: localURL, pointsCount: 10, fps: 10.0, cutoff: 80,
extractSmartDJ: true, progress: nil)
SmartDJCache.shared.store(SmartDJProfile(
bpm: nil, silenceStart: result.silenceStart,
silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS
), for: path)
onDeviceCount += 1
} catch {
onDeviceErrors += 1
}
}
// 3. Summary
await MainActor.run {
var parts: [String] = []
if let s = serverMsg { parts.append(s) }
if onDeviceCount > 0 { parts.append("\(onDeviceCount) on-device") }
if onDeviceErrors > 0 { parts.append("\(onDeviceErrors) failed") }
if parts.isEmpty { parts.append("✓ Nothing new to analyze") }
analyzeStatus = .done
analyzeMessage = parts.joined(separator: " · ")
}
try? await Task.sleep(for: .seconds(6))
await MainActor.run {
if analyzeStatus == .done { analyzeStatus = .idle; analyzeMessage = nil }
}
}
}
private func triggerVisPrecompute() {
analyzeStatus = .analyzing
analyzeMessage = "Generating visualizer frames on server..."

View file

@ -483,18 +483,68 @@ class SmartCrossfadeManager: ObservableObject {
private func fetchProfile(for song: Song) async -> SmartDJProfile? {
guard CompanionSettings.shared.smartDJEnabled else { return nil }
guard let path = song.path else { return nil }
// 1. Memory/disk cache
if let cached = profiles[song.id] { return cached }
if let cached = SmartDJCache.shared.get(path) {
profiles[song.id] = cached
return cached
}
// 2. Companion API (server-side analysis)
if CompanionSettings.shared.isEnabled {
do {
let profile = try await api.fetchProfile(relativePath: path)
profiles[song.id] = profile
SmartDJCache.shared.store(profile, for: path)
log("Profile (API): trailing=\(profile.silenceStart ?? 0)s, " +
"leading=\(profile.silenceEnd ?? 0)s, " +
"LUFS=\(profile.loudnessLUFS ?? 0)")
return profile
} catch {
log("Profile API failed, trying on-device: \(error.localizedDescription)")
}
}
// 3. On-device analysis fallback for local/downloaded files only.
// Runs OfflineAudioAnalyzer's combined pass to extract silence boundaries
// and an approximate integrated loudness in one file read.
if let localURL = OfflineManager.shared.localURL(for: song.id) {
if let profile = await analyzeOnDevice(song: song, url: localURL) {
profiles[song.id] = profile
SmartDJCache.shared.store(profile, for: path)
return profile
}
}
return nil
}
/// On-device SmartDJ profile extraction using the combined OfflineAudioAnalyzer pass.
/// Returns silence boundaries and approximate LUFS without requiring Companion API.
private func analyzeOnDevice(song: Song, url: URL) async -> SmartDJProfile? {
log("On-device analysis: \(song.title)")
do {
let profile = try await api.fetchProfile(relativePath: path)
profiles[song.id] = profile
log("Profile: trailing=\(profile.silenceStart ?? 0)s, " +
"leading=\(profile.silenceEnd ?? 0)s, " +
"LUFS=\(profile.loudnessLUFS ?? 0)")
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
url: url,
pointsCount: 20, // minimal for speed we only need the SmartDJ data
fps: 15.0, // low fps = fewer FFT frames = faster
cutoff: 80,
extractSmartDJ: true,
progress: nil
)
let profile = SmartDJProfile(
bpm: nil,
silenceStart: result.silenceStart,
silenceEnd: result.silenceEnd,
loudnessLUFS: result.loudnessLUFS
)
log("On-device profile: trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s, " +
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
"LUFS≈\(String(format: "%.1f", result.loudnessLUFS ?? 0))")
return profile
} catch {
log("Profile failed: \(error.localizedDescription)")
log("On-device analysis failed: \(error.localizedDescription)")
return nil
}
}

View file

@ -382,27 +382,22 @@ struct NowPlayingView: View {
// MARK: - Visualizer Layer
@ViewBuilder
@ViewBuilder
private func visualizerLayer(geo: GeometryProxy) -> some View {
let height = isLandscape
? geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7)
: geo.size.height * visSettings.nowPlayingHeightPct
if visSettings.enabled && visSettings.nowPlayingEnabled {
if isLandscape {
VStack(spacing: 0) {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(maxWidth: .infinity)
.frame(height: geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7))
.allowsHitTesting(false)
}
.ignoresSafeArea()
} else {
VStack {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(height: geo.size.height * visSettings.nowPlayingHeightPct)
.allowsHitTesting(false)
}
.ignoresSafeArea()
VStack {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(maxWidth: .infinity)
.frame(height: height)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
}

View file

@ -13,6 +13,7 @@ class VisualizerSettings: ObservableObject {
@Published var sensitivity: Double { didSet { save("vis_sensitivity", sensitivity) } }
@Published var fps: Double { didSet { save("vis_fps", fps) } }
@Published var realAudioAnalysis: Bool { didSet { save("vis_real_fft", realAudioAnalysis) } }
@Published var dynamicGainEnabled: Bool { didSet { save("vis_dynamic_gain", dynamicGainEnabled) } }
// Wave
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
@ -76,6 +77,7 @@ class VisualizerSettings: ObservableObject {
sensitivity = { let v = d.double(forKey: "vis_sensitivity"); return v > 0 ? v : 1.5 }()
fps = { let v = d.double(forKey: "vis_fps"); return v > 0 ? v : 60.0 }()
realAudioAnalysis = d.object(forKey: "vis_real_fft") as? Bool ?? true
dynamicGainEnabled = d.object(forKey: "vis_dynamic_gain") as? Bool ?? true
waveOffsetTop = d.double(forKey: "vis_wave_offset")
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
barCornerRadius = d.double(forKey: "vis_bar_radius")
@ -138,62 +140,74 @@ final class WaveStateCache {
private init() {}
}
// MARK: - Touch Ripple (file-private so VisualizerLevelBox can reference it before MitsuhaVisualizerView)
// MARK: - Level Box
/// Holds all mutable per-frame rendering state.
/// Conforms to ObservableObject but has ZERO @Published properties,
/// so SwiftUI never observes it and never warns "modifying state during view update".
/// TimelineView drives rerenders independently; the Canvas just reads from this box directly.
fileprivate final class VisualizerLevelBox: ObservableObject {
var displayLevels: [Float] = []
var peakFollower: Float = 0.01
var levelHistoryBuf: [[Float]] = []
var historyWriteIdx: Int = 0
var wobblePhaseOffset: Double = 0
var lastTimelineDate: Date? = nil
}
// MARK: - Main Visualizer View
struct MitsuhaVisualizerView: View {
/// Optional override levels for previews. When nil, pulls from AudioPlayer.
var previewLevels: [Float]? = nil
let isPlaying: Bool
let accentColor: Color
var compact: Bool = false
@ObservedObject var settings = VisualizerSettings.shared
@ObservedObject var albumColors = AlbumColorExtractor.shared
@State private var touchRipples: [TouchRipple] = []
@State private var displayLevels: [Float] = []
struct TouchRipple: Identifiable {
let id = UUID()
let x: CGFloat
let createdAt: Date
}
// @StateObject: SwiftUI holds instance across renders; no @Published = no observation warnings
@StateObject private var box = VisualizerLevelBox()
private static let historySize = 16 // ~267ms at 60fps 250ms original dispatch_after
var body: some View {
Group {
if settings.enabled {
GeometryReader { geo in
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
// PULL: Grab latest levels right before rendering no @Published thrashing
// Ternary required: if/else assignment inside @ViewBuilder returns () which isn't View
let rawLevels: [Float] = isPlaying
? (previewLevels ?? AudioPlayer.shared.currentLevels())
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
let _ = updateDisplayLevelsIfNeeded(newRawLevels: rawLevels)
Canvas(opaque: false) { context, size in
let pts = displayLevels.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: displayLevels
guard pts.count >= 2 else { return }
switch settings.style {
case .wave: drawWave(ctx: context, size: size, levels: pts, date: timeline.date)
case .bar: drawBars(ctx: context, size: size, levels: pts)
case .line: drawLine(ctx: context, size: size, levels: pts)
}
// .animation fires as part of SwiftUI's animation pass always redraws Canvas.
// The Canvas closure must capture a CHANGING VALUE TYPE (timeline.date) so
// SwiftUI knows to re-invoke the draw closure each tick. Without this, Canvas
// sees only `box` (a class reference SwiftUI can't track class mutations)
// and skips the redraw, producing a static frozen wave.
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
let rawLevels: [Float] = isPlaying
? (previewLevels ?? AudioPlayer.shared.currentLevels())
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
let _ = updateDisplayLevels(newRawLevels: rawLevels)
let _ = updateWobblePhase(date: timeline.date)
// Capture the changing Date so SwiftUI detects a change and
// calls the Canvas drawing closure on every timeline tick.
let frameDate = timeline.date
Canvas(opaque: false) { context, size in
// Reference frameDate so the compiler keeps the capture.
// This tells SwiftUI the closure's environment changed redraw.
let _ = frameDate
let pts = box.displayLevels.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: box.displayLevels
guard pts.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)
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
addRipple(at: value.location.x / geo.size.width)
}
)
}
// Smooth fade instead of hard cut keeps TimelineView alive
.opacity(isPlaying ? 1 : Double(settings.idleAmplitude) > 0 ? 0.4 : 0)
.animation(.easeInOut(duration: 0.8), value: isPlaying)
.opacity(isPlaying ? 1.0 : 0.35)
.animation(.easeInOut(duration: 0.6), value: isPlaying)
.onAppear {
let cached = compact ? WaveStateCache.shared.compactLevels : []
displayLevels = cached.isEmpty
box.displayLevels = cached.isEmpty
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: cached
}
@ -201,27 +215,18 @@ struct MitsuhaVisualizerView: View {
}
}
/// Called every frame inside TimelineView updates displayLevels in-place
/// Returns Void; the `let _ =` assignment is just to run code inside the ViewBuilder
private func updateDisplayLevelsIfNeeded(newRawLevels: [Float]) -> Bool {
// Must dispatch to avoid mutating @State during view render
DispatchQueue.main.async {
self.updateDisplayLevels(newRawLevels: newRawLevels)
// MARK: - Wobble Phase (continuous time accumulator)
@discardableResult
private func updateWobblePhase(date: Date) -> Bool {
if let last = box.lastTimelineDate {
let delta = date.timeIntervalSince(last)
box.wobblePhaseOffset += min(delta, 0.1)
}
box.lastTimelineDate = date
return true
}
// MARK: - Touch Ripples
private func addRipple(at x: CGFloat) {
let clamped = min(max(x, 0), 1)
if let last = touchRipples.last, Date().timeIntervalSince(last.createdAt) < 0.04 { return }
touchRipples.append(TouchRipple(x: clamped, createdAt: Date()))
let now = Date()
touchRipples.removeAll { now.timeIntervalSince($0.createdAt) > 1.0 }
if touchRipples.count > 25 { touchRipples.removeFirst() }
}
// MARK: - Temporal Smoothing & Log Binning
private func updateDisplayLevels(newRawLevels: [Float]) {
@ -278,17 +283,41 @@ struct MitsuhaVisualizerView: View {
}
}
// Temporal Smoothing (Viscosity)
if displayLevels.count != count {
displayLevels = targetLevels
// Temporal Smoothing frame-rate independent viscosity.
let targetFPS = max(1.0, settings.effectiveFPS)
let fpsScale = Float(60.0 / targetFPS)
let smoothFactor = min(Float(settings.viscosity) * fpsScale, 1.0)
// Dynamic Gain / Peak Follower
let frameMax = targetLevels.max() ?? 0.0
if frameMax > box.peakFollower {
box.peakFollower = box.peakFollower + (frameMax - box.peakFollower) * 0.3
} else {
box.peakFollower = max(box.peakFollower * 0.997, 0.005)
}
if settings.dynamicGainEnabled {
let normFactor = Float(1.0) / max(box.peakFollower, 0.005)
targetLevels = targetLevels.map { min($0 * normFactor, 1.0) }
}
if box.displayLevels.count != count {
box.displayLevels = targetLevels
} else {
let smoothFactor = Float(settings.viscosity)
for i in 0..<count {
displayLevels[i] = displayLevels[i] + (targetLevels[i] - displayLevels[i]) * smoothFactor
box.displayLevels[i] = box.displayLevels[i] + (targetLevels[i] - box.displayLevels[i]) * smoothFactor
}
}
// Ring Buffer for temporal depth ghost
if box.levelHistoryBuf.count < MitsuhaVisualizerView.historySize {
box.levelHistoryBuf.append(box.displayLevels)
} else {
box.levelHistoryBuf[box.historyWriteIdx] = 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 = displayLevels }
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
}
// MARK: - Colors
@ -367,51 +396,64 @@ struct MitsuhaVisualizerView: View {
// MARK: - Wave
private func drawWave(ctx: GraphicsContext, size: CGSize, levels: [Float], date: Date) {
private func drawWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
let w = size.width
let h = size.height
let idleVal = CGFloat(compact ? settings.miniIdleAmplitude : settings.idleAmplitude)
guard levels.count >= 2 else { return }
let spacing = w / CGFloat(levels.count - 1)
let now = date.timeIntervalSinceReferenceDate
let count = levels.count
let spacing = w / CGFloat(count - 1)
let now = continuousTime
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let baseline = h - baseLift - offset
let ampScale: CGFloat = compact ? CGFloat(settings.miniAmplitude) : CGFloat(settings.npAmplitude)
let points = levels.enumerated().map { i, lev -> CGPoint in
var points = levels.enumerated().map { i, lev -> CGPoint in
let x = CGFloat(i) * spacing
let nx = x / w
let baseAmp = CGFloat(lev)
var ripple: CGFloat = 0
for rp in touchRipples {
let age = CGFloat(now - rp.createdAt.timeIntervalSinceReferenceDate)
let decay = max(0, 1.0 - age * 1.4)
let dist = abs(nx - rp.x)
let radius: CGFloat = 0.08 + age * 1.2
if dist < radius {
let prox = 1.0 - (dist / radius)
ripple += sin(dist * 30 - age * 12) * prox * decay * 0.35
}
}
// Organic wobble: gentle rolling sine based on time and position
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
let totalAmp = max(idleVal, baseAmp + ripple + organicWobble)
let totalAmp = max(idleVal, baseAmp + organicWobble)
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
}
// Layer 1: True index phase-shifted depth liquid parallax
// Endpoint Anchoring (improvement #13)
// Pin first and last points exactly to baseline so the wave cleanly
// rises from and returns to the horizon matching the original tweak.
points[0] = CGPoint(x: 0, y: baseline)
points[count - 1] = CGPoint(x: w, y: baseline)
// Layer 1: Temporal depth ghost (improvement #11)
// Use the oldest ring-buffer frame (~250ms ago) for the shadow wave,
// matching the original's 0.25s dispatch_after delay exactly.
let ctxDepthOffset = compact ? CGFloat(settings.miniDepthOffset) : CGFloat(settings.depthOffset)
let ctxDepthOpacity = compact ? settings.miniDepthOpacity : settings.depthOpacity
let depthPts = points.enumerated().map { i, pt -> CGPoint in
let shiftedIndex = (i + 2) % points.count
let shiftedPt = points[shiftedIndex]
return CGPoint(x: pt.x, y: shiftedPt.y + ctxDepthOffset + 5)
let ghostLevels: [Float]
let histFull = box.levelHistoryBuf.count >= MitsuhaVisualizerView.historySize
if histFull {
ghostLevels = box.levelHistoryBuf[box.historyWriteIdx]
} else {
ghostLevels = box.levelHistoryBuf.first ?? Array(repeating: 0, count: count)
}
let ghostCount = max(ghostLevels.count, 2)
var depthPts = (0..<count).map { i -> CGPoint in
// Sample the ghost frame at the same normalised position
let srcF = Float(i) / Float(count - 1) * Float(ghostCount - 1)
let lo = min(Int(srcF), ghostCount - 1)
let hi = min(lo + 1, ghostCount - 1)
let frac = CGFloat(srcF - Float(lo))
let ghostLev = CGFloat(ghostLevels[lo]) * (1 - frac) + CGFloat(ghostLevels[hi]) * frac
let ghostAmp = max(idleVal, ghostLev)
let x = CGFloat(i) * spacing
return CGPoint(x: x, y: baseline - (ghostAmp * h * ampScale) + ctxDepthOffset + 5)
}
// Anchor ghost endpoints too
depthPts[0] = CGPoint(x: 0, y: baseline + ctxDepthOffset)
depthPts[count - 1] = CGPoint(x: w, y: baseline + ctxDepthOffset)
let depthCurve = smoothCurve(depthPts)
var depthFill = Path()
depthFill.move(to: CGPoint(x: 0, y: h))
@ -642,8 +684,9 @@ struct VisualizerSettingsView: View {
sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...4.0, step: 0.1, format: "%.1f")
sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f")
Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink)
Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink)
} header: { Text("SHARED / ADVANCED") } footer: {
Text("Viscosity is the most important slider — it controls how quickly the wave reacts. 0.150.25 = heavy, slow liquid (cinematic). 0.5 = responsive. 0.8+ = snappy EQ meter.\n\nSensitivity multiplies the incoming audio. Push up if the wave looks flat.\n\nBase multiplier is a second gain stage for FFT data. Higher values reveal quiet passages.\n\nFrequency cutoff limits how many FFT bins are used. At 80 (default), mostly bass/mids. At 150+, treble detail like hi-hats shows up.\n\nReal Audio Analysis uses FFT on downloaded songs. Streams always use simulated animation.\n\nFPS drops to 24 automatically in Low Power Mode.")
Text("Viscosity controls how quickly the wave reacts. 0.150.25 = heavy liquid. 0.5 = responsive. 0.8+ = snappy EQ.\n\nSensitivity multiplies incoming audio. Base multiplier is a second gain stage for FFT.\n\nDynamic Gain automatically normalises the wave amplitude across loud and quiet tracks — keeps the wave full on soft passages without clipping on loud ones.\n\nFPS drops to 24 in Low Power Mode.")
}
// Smart DJ Crossfade
@ -780,7 +823,8 @@ struct VisualizerSettingsView: View {
private func resetDefaults() {
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
settings.style = .wave; settings.numberOfPoints = 10; settings.sensitivity = 1.5
settings.fps = 60; settings.realAudioAnalysis = true; settings.waveOffsetTop = 0
settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true
settings.waveOffsetTop = 0
settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
settings.colorMode = .dynamic; settings.alpha = 0.6; settings.viscosity = 0.25
settings.frequencyCutoff = 80; settings.baseMultiplier = 40.0

View file

@ -4,14 +4,23 @@ import Accelerate
/// Processes an entire audio file faster than real-time, producing per-frame FFT data
/// that can be cached and played back in sync with the audio.
/// Also optionally extracts SmartDJ profile data (silence boundaries + LUFS) in the same pass.
actor OfflineAudioAnalyzer {
static let shared = OfflineAudioAnalyzer()
/// Progress callback (0.0 to 1.0)
typealias ProgressCallback = @Sendable (Float) -> Void
/// Analyze an audio file and return an array of FFT frames.
/// Each frame is an array of `pointsCount` floats (0.0-1.0) representing frequency band amplitudes.
// MARK: - Combined Analysis Result
struct CombinedResult {
let visFrames: [[Float]]
let silenceEnd: Double? // leading silence end in seconds
let silenceStart: Double? // trailing silence start in seconds
let loudnessLUFS: Double? // approximate integrated loudness
}
// MARK: - Visualizer-only (legacy entry point)
func analyze(
url: URL,
pointsCount: Int = 20,
@ -20,96 +29,147 @@ actor OfflineAudioAnalyzer {
eqBoostFactor: Float = 3.5,
progress: ProgressCallback? = nil
) throws -> [[Float]] {
let r = try analyzeWithSmartDJ(url: url, pointsCount: pointsCount, fps: fps,
cutoff: cutoff, eqBoostFactor: eqBoostFactor,
extractSmartDJ: false, progress: progress)
return r.visFrames
}
// MARK: - Combined pass: vis frames + SmartDJ profile in one file read
/// Reads the file once, producing visualiser frames AND silence/loudness data.
/// Set `extractSmartDJ: false` to skip the SmartDJ computation and save time.
func analyzeWithSmartDJ(
url: URL,
pointsCount: Int = 20,
fps: Double = 30.0,
cutoff: Int = 90,
eqBoostFactor: Float = 3.5,
extractSmartDJ: Bool = true,
progress: ProgressCallback? = nil
) throws -> CombinedResult {
let file = try AVAudioFile(forReading: url)
let format = file.processingFormat
let sampleRate = format.sampleRate
let totalFrames = file.length
// How many audio frames per visualizer frame
let durationSec = Double(totalFrames) / sampleRate
let audioFramesPerVisFrame = AVAudioFrameCount(sampleRate / fps)
// Use power-of-2 buffer for FFT
let fftSize = 1024
let bufferSize = max(AVAudioFrameCount(fftSize), audioFramesPerVisFrame)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
throw NSError(domain: "OfflineAnalyzer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create buffer"])
}
let log2n = vDSP_Length(log2(Double(fftSize)))
guard let fftSetup = vDSP_create_fftsetup(log2n, Int32(kFFTRadix2)) else {
throw NSError(domain: "OfflineAnalyzer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create FFT setup"])
}
defer { vDSP_destroy_fftsetup(fftSetup) }
let halfSize = fftSize / 2
var visualizerData: [[Float]] = []
// Estimate total frames for progress
let estimatedVisFrames = Int(Double(totalFrames) / Double(audioFramesPerVisFrame))
visualizerData.reserveCapacity(estimatedVisFrames)
// Reusable buffers
var window = [Float](repeating: 0, count: fftSize)
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// SmartDJ state
let silenceThreshold: Float = 0.008 // RMS below this = silence
var leadingSilenceEndSec: Double? = nil // first non-silent moment
var trailingSilenceStartSec: Double? = nil // last non-silent moment
var sumSquares: Double = 0.0
var sampleCount: Int64 = 0
var filePositionSec: Double { Double(file.framePosition) / sampleRate }
var frameIndex = 0
while file.framePosition < totalFrames {
// Read a chunk
let framesToRead = min(bufferSize, AVAudioFrameCount(totalFrames - file.framePosition))
buffer.frameLength = 0
try file.read(into: buffer, frameCount: framesToRead)
guard let channelData = buffer.floatChannelData?[0] else { continue }
let actualFrames = Int(buffer.frameLength)
let chunkStartSec = filePositionSec - Double(actualFrames) / sampleRate
// SmartDJ: RMS per chunk
if extractSmartDJ && actualFrames > 0 {
var rms: Float = 0
vDSP_rmsqv(channelData, 1, &rms, vDSP_Length(actualFrames))
if rms > silenceThreshold {
if leadingSilenceEndSec == nil {
leadingSilenceEndSec = chunkStartSec
}
trailingSilenceStartSec = chunkStartSec + Double(actualFrames) / sampleRate
}
// Accumulate for integrated loudness
var sumSq: Float = 0
vDSP_measqv(channelData, 1, &sumSq, vDSP_Length(actualFrames))
sumSquares += Double(sumSq) * Double(actualFrames)
sampleCount += Int64(actualFrames)
}
// Visualiser FFT frames
guard actualFrames >= fftSize else {
// Pad with zeros for the last partial buffer
if actualFrames > 0 {
let frame = processFFTFrame(
channelData: channelData,
frameCount: actualFrames,
fftSize: fftSize,
halfSize: halfSize,
window: window,
fftSetup: fftSetup,
pointsCount: pointsCount,
cutoff: cutoff,
eqBoostFactor: eqBoostFactor
)
visualizerData.append(frame)
visualizerData.append(processFFTFrame(
channelData: channelData, frameCount: actualFrames,
fftSize: fftSize, halfSize: halfSize, window: window,
fftSetup: fftSetup, pointsCount: pointsCount,
cutoff: cutoff, eqBoostFactor: eqBoostFactor))
}
break
}
// Process one or more vis frames from this buffer
var sampleOffset = 0
while sampleOffset + fftSize <= actualFrames {
let frame = processFFTFrame(
visualizerData.append(processFFTFrame(
channelData: channelData.advanced(by: sampleOffset),
frameCount: fftSize,
fftSize: fftSize,
halfSize: halfSize,
window: window,
fftSetup: fftSetup,
pointsCount: pointsCount,
cutoff: cutoff,
eqBoostFactor: eqBoostFactor
)
visualizerData.append(frame)
frameCount: fftSize, fftSize: fftSize, halfSize: halfSize,
window: window, fftSetup: fftSetup, pointsCount: pointsCount,
cutoff: cutoff, eqBoostFactor: eqBoostFactor))
sampleOffset += Int(audioFramesPerVisFrame)
frameIndex += 1
// Report progress every 50 frames
if frameIndex % 50 == 0, let progress = progress {
let pct = Float(file.framePosition) / Float(totalFrames)
progress(pct)
if frameIndex % 50 == 0 {
progress?(Float(file.framePosition) / Float(totalFrames))
}
}
}
progress?(1.0)
return visualizerData
// Compute approximate integrated LUFS
// Uses mean square dBFS as a simplified approximation of BS.1770.
// Not true K-weighted LUFS but accurate enough for volume normalisation.
var loudnessLUFS: Double? = nil
if extractSmartDJ && sampleCount > 0 {
let meanSquare = sumSquares / Double(sampleCount)
if meanSquare > 0 {
let lufs = 20.0 * log10(sqrt(meanSquare))
loudnessLUFS = lufs
}
}
// Guard silence detections: must be within plausible range
let safeLeading: Double? = {
guard let t = leadingSilenceEndSec, t > 0.05, t < durationSec * 0.25 else { return nil }
return t
}()
let safeTrailing: Double? = {
guard let t = trailingSilenceStartSec, t < durationSec - 0.5, t > durationSec * 0.5 else { return nil }
return t
}()
return CombinedResult(
visFrames: visualizerData,
silenceEnd: safeLeading,
silenceStart: safeTrailing,
loudnessLUFS: loudnessLUFS
)
}
/// Process a single FFT frame from raw audio samples