From f1f98fbf7df4ef95acf406234f71e7e357de100b Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sat, 4 Apr 2026 23:17:47 -0700 Subject: [PATCH] Finaly fixed Visualizer tweaks to make it more jello-y --- Shared/Audio/AudioPlayer.swift | 56 +++- .../Companion/CompanionSettingsView.swift | 256 +++++++++--------- .../Companion/SmartCrossfadeManager.swift | 66 ++++- iOS/Views/NowPlaying/NowPlayingView.swift | 29 +- .../Visualizer/MitsuhaVisualizerView.swift | 232 +++++++++------- .../Visualizer/OfflineAudioAnalyzer.swift | 166 ++++++++---- 6 files changed, 488 insertions(+), 317 deletions(-) diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 3461fa8..cdf54d4 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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 } diff --git a/iOS/Views/Companion/CompanionSettingsView.swift b/iOS/Views/Companion/CompanionSettingsView.swift index fd5b102..ab0fc37 100644 --- a/iOS/Views/Companion/CompanionSettingsView.swift +++ b/iOS/Views/Companion/CompanionSettingsView.swift @@ -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..." diff --git a/iOS/Views/Companion/SmartCrossfadeManager.swift b/iOS/Views/Companion/SmartCrossfadeManager.swift index e4b891f..de1fc4c 100644 --- a/iOS/Views/Companion/SmartCrossfadeManager.swift +++ b/iOS/Views/Companion/SmartCrossfadeManager.swift @@ -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 } } diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index c76d282..58e24a1 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -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() } } diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index c19b0c5..d1077a9 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -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..= 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.. 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.15–0.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.15–0.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 diff --git a/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift b/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift index 2a7e7d0..3f47c73 100644 --- a/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift +++ b/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift @@ -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