- AudioTapProcessor: shared MTAudioProcessingTap with lock-free PCM ring buffer - Pre-allocated vDSP FFT (1024-sample, Hann window, log-frequency 30-band output) - Zero per-frame heap allocation in FFT path - Shared tap serves both FFT visualizer and Shazam simultaneously Fixes (blockers for tap to work): - radioGoLive/radioSeekBack now update self.playerItem (was orphaned) - Tap reinstalled on every AVPlayerItem swap (seek, live, station change) - Tap removed on background, reinstalled on foreground - Tap removed on radio→music transition Shazam rework: - Uses shared AudioTapProcessor instead of creating its own tap - Fixes tap conflict where Shazam overwrote FFT audioMix - 500ms wait for tapPrepare callback (sourceFormat timing race) - Fixed pre-existing bug: stopAll() audio session never restored after mic fallback Debug capture: - Capture Audio Tap button in Visualizer Settings - Records 5s of raw tap PCM as playable WAV file - Uses actual stream sample rate (not hardcoded 44100) - Share sheet via Notification pattern (survives view dismiss) - Spinner auto-resets on appear if capture interrupted by background Also includes from main branch: - Edit History UI, batch undo, companion API 7-bug fix - Recently Played tab, Discover section, Play Queue sync, Share links"
1360 lines
65 KiB
Swift
1360 lines
65 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Per-View Independent Configuration
|
||
/// Holds the full visual configuration for one visualizer context (Now Playing or Mini Player).
|
||
/// Each style stores its own point count and sensitivity so switching styles
|
||
/// never bleeds parameters from a different style.
|
||
struct ViewVisualizerConfig: Codable {
|
||
// MARK: Style & Color
|
||
var style: VisualizerSettings.Style = .wave
|
||
var colorMode: VisualizerSettings.ColorMode = .dynamic
|
||
var alpha: Double = 0.6
|
||
// Custom color stored as components — Color is not Codable
|
||
var customColorR: Double = 1.0
|
||
var customColorG: Double = 0.176
|
||
var customColorB: Double = 0.333
|
||
|
||
// MARK: Per-style point counts (independent — switching styles uses its own last value)
|
||
var wavePoints: Int = 9
|
||
var barPoints: Int = 12
|
||
var linePoints: Int = 10
|
||
var siriPoints: Int = 16
|
||
|
||
// MARK: Per-style sensitivity
|
||
var waveSensitivity: Double = 0.9
|
||
var barSensitivity: Double = 1.0
|
||
var lineSensitivity: Double = 0.9
|
||
var siriSensitivity: Double = 1.0
|
||
|
||
// MARK: Computed from active style
|
||
var numberOfPoints: Int {
|
||
get {
|
||
switch style {
|
||
case .wave: return wavePoints
|
||
case .bar: return barPoints
|
||
case .line: return linePoints
|
||
case .siriWave: return siriPoints
|
||
}
|
||
}
|
||
set {
|
||
switch style {
|
||
case .wave: wavePoints = newValue
|
||
case .bar: barPoints = newValue
|
||
case .line: linePoints = newValue
|
||
case .siriWave: siriPoints = newValue
|
||
}
|
||
}
|
||
}
|
||
|
||
var sensitivity: Double {
|
||
get {
|
||
switch style {
|
||
case .wave: return waveSensitivity
|
||
case .bar: return barSensitivity
|
||
case .line: return lineSensitivity
|
||
case .siriWave: return siriSensitivity
|
||
}
|
||
}
|
||
set {
|
||
switch style {
|
||
case .wave: waveSensitivity = newValue
|
||
case .bar: barSensitivity = newValue
|
||
case .line: lineSensitivity = newValue
|
||
case .siriWave: siriSensitivity = newValue
|
||
}
|
||
}
|
||
}
|
||
|
||
var customColor: Color {
|
||
Color(red: customColorR, green: customColorG, blue: customColorB)
|
||
}
|
||
|
||
// MARK: Defaults
|
||
static var nowPlayingDefault: ViewVisualizerConfig {
|
||
var c = ViewVisualizerConfig()
|
||
c.wavePoints = 9; c.barPoints = 12; c.linePoints = 10; c.siriPoints = 16
|
||
return c
|
||
}
|
||
static var miniPlayerDefault: ViewVisualizerConfig {
|
||
var c = ViewVisualizerConfig()
|
||
c.style = .wave; c.alpha = 0.5
|
||
c.wavePoints = 8; c.barPoints = 10; c.linePoints = 8; c.siriPoints = 12
|
||
return c
|
||
}
|
||
}
|
||
|
||
// MARK: - Visualizer Settings (global + per-view configs)
|
||
class VisualizerSettings: ObservableObject {
|
||
static let shared = VisualizerSettings()
|
||
|
||
// MARK: Global toggles
|
||
@Published var enabled: Bool { didSet { save("vis_enabled", enabled) } }
|
||
@Published var nowPlayingEnabled: Bool { didSet { save("vis_nowplaying", nowPlayingEnabled) } }
|
||
@Published var miniPlayerEnabled: Bool { didSet { save("vis_miniplayer", miniPlayerEnabled) } }
|
||
|
||
// MARK: Per-view independent configs
|
||
@Published var nowPlaying: ViewVisualizerConfig { didSet { saveConfig("vis_np_config", nowPlaying) } }
|
||
@Published var miniPlayer: ViewVisualizerConfig { didSet { saveConfig("vis_mini_config", miniPlayer) } }
|
||
|
||
// MARK: Global physics (shared between views)
|
||
@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) } }
|
||
@Published var viscosity: Double { didSet { save("vis_viscosity", viscosity) } }
|
||
@Published var frequencyCutoff: Int { didSet { save("vis_freq_cutoff", frequencyCutoff) } }
|
||
@Published var baseMultiplier: Double { didSet { save("vis_base_mult", baseMultiplier) } }
|
||
|
||
// MARK: Wave-specific global
|
||
@Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } }
|
||
|
||
// MARK: Bar-specific global
|
||
@Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } }
|
||
@Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } }
|
||
|
||
// MARK: Line-specific global
|
||
@Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } }
|
||
|
||
// MARK: Now Playing layout / depth
|
||
@Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } }
|
||
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
|
||
@Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } }
|
||
@Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } }
|
||
@Published var depthOffset: Double { didSet { save("vis_depth_offset", depthOffset) } }
|
||
@Published var depthOpacity: Double { didSet { save("vis_depth_opacity",depthOpacity) } }
|
||
@Published var idleAmplitude: Double { didSet { save("vis_idle_amp", idleAmplitude) } }
|
||
|
||
// MARK: Mini Player layout / depth
|
||
@Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } }
|
||
@Published var miniOpacity: Double { didSet { save("vis_mini_opacity", miniOpacity) } }
|
||
@Published var miniAmplitude: Double { didSet { save("vis_mini_amplitude", miniAmplitude) } }
|
||
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
|
||
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
|
||
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
|
||
|
||
enum Style: String, CaseIterable, Codable {
|
||
case wave = "Wave"
|
||
case bar = "Bar"
|
||
case line = "Line"
|
||
case siriWave = "Siri"
|
||
}
|
||
|
||
enum ColorMode: String, CaseIterable, Codable {
|
||
case dynamic = "Dynamic"
|
||
case albumArt = "Album Art"
|
||
case vibrant = "Vibrant"
|
||
case custom = "Custom"
|
||
}
|
||
|
||
private init() {
|
||
let d = UserDefaults.standard
|
||
|
||
enabled = d.object(forKey: "vis_enabled") as? Bool ?? true
|
||
nowPlayingEnabled = d.object(forKey: "vis_nowplaying") as? Bool ?? true
|
||
miniPlayerEnabled = d.object(forKey: "vis_miniplayer") as? Bool ?? true
|
||
|
||
// Per-view configs — load from JSON or use defaults
|
||
let dec = JSONDecoder()
|
||
if let data = d.data(forKey: "vis_np_config"),
|
||
let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) {
|
||
nowPlaying = cfg
|
||
} else {
|
||
nowPlaying = .nowPlayingDefault
|
||
}
|
||
if let data = d.data(forKey: "vis_mini_config"),
|
||
let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) {
|
||
miniPlayer = cfg
|
||
} else {
|
||
miniPlayer = .miniPlayerDefault
|
||
}
|
||
|
||
// Global physics
|
||
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
|
||
viscosity = { let v = d.double(forKey: "vis_viscosity"); return v > 0 ? v : 0.25 }()
|
||
frequencyCutoff = { let v = d.integer(forKey: "vis_freq_cutoff"); return v > 0 ? v : 80 }()
|
||
baseMultiplier = { let v = d.double(forKey: "vis_base_mult"); return v > 0 ? v : 25.0 }()
|
||
|
||
// Style-specific globals
|
||
waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }()
|
||
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
|
||
barCornerRadius = d.object(forKey: "vis_bar_radius") as? Double ?? 0.0
|
||
lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }()
|
||
|
||
// Now Playing layout
|
||
nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }()
|
||
waveOffsetTop = d.object(forKey: "vis_wave_offset") as? Double ?? 0.0
|
||
npAmplitude = { let v = d.double(forKey: "vis_np_amplitude"); return v > 0 ? v : 0.45 }()
|
||
npBaseLift = d.object(forKey: "vis_np_baselift") as? Double ?? 130.0
|
||
// Fix: use object(forKey:) ?? default so setting value to 0 is persisted correctly
|
||
depthOffset = d.object(forKey: "vis_depth_offset") as? Double ?? 15.0
|
||
depthOpacity = d.object(forKey: "vis_depth_opacity") as? Double ?? 0.2
|
||
idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }()
|
||
|
||
// Mini Player layout
|
||
miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }()
|
||
miniOpacity = d.object(forKey: "vis_mini_opacity") as? Double ?? 0.5
|
||
miniAmplitude = { let v = d.double(forKey: "vis_mini_amplitude"); return v > 0 ? v : 0.7 }()
|
||
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
|
||
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
|
||
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
|
||
}
|
||
|
||
var effectiveFPS: Double {
|
||
ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps
|
||
}
|
||
|
||
// MARK: - Persistence
|
||
|
||
private func save(_ key: String, _ value: Any) {
|
||
pendingSaves[key] = value
|
||
scheduleFlush()
|
||
}
|
||
|
||
private func saveConfig(_ key: String, _ config: ViewVisualizerConfig) {
|
||
if let data = try? JSONEncoder().encode(config) {
|
||
pendingSaves[key] = data
|
||
scheduleFlush()
|
||
}
|
||
}
|
||
|
||
// Debounce UserDefaults writes to 500ms so rapid slider drags don't
|
||
// flood the defaults system. The Task is explicitly @MainActor so
|
||
// pendingSaves is only ever read/written on the main thread — no data race.
|
||
private func scheduleFlush() {
|
||
saveTask?.cancel()
|
||
saveTask = Task { @MainActor [weak self] in
|
||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||
guard !Task.isCancelled, let self else { return }
|
||
for (k, v) in self.pendingSaves {
|
||
UserDefaults.standard.set(v, forKey: k)
|
||
}
|
||
self.pendingSaves.removeAll()
|
||
}
|
||
}
|
||
|
||
private var pendingSaves: [String: Any] = [:]
|
||
private var saveTask: Task<Void, Never>?
|
||
}
|
||
|
||
// MARK: - Wave State Cache (shared between mini player and DI for morph continuity)
|
||
/// Single source of truth for compact visualizer levels.
|
||
/// Both MiniPlayerBar and DynamicIslandView seed from here on appear,
|
||
/// so the wave doesn't reset to idle when matchedGeometryEffect transitions between them.
|
||
final class WaveStateCache {
|
||
static let shared = WaveStateCache()
|
||
var compactLevels: [Float] = []
|
||
private init() {}
|
||
}
|
||
|
||
// MARK: - Level Box
|
||
/// Per-instance mutable render state. Zero @Published properties → no observation warnings.
|
||
/// All working buffers pre-allocated here so the 60fps render loop does zero heap allocation.
|
||
fileprivate final class VisualizerLevelBox: ObservableObject {
|
||
var displayLevels: [Float] = []
|
||
var targetLevels: [Float] = [] // scratch buffer — pre-allocated, mutated in-place each frame
|
||
var idleLevels: [Float] = [] // flat idle wave — pre-allocated, updated only when count changes
|
||
var peakFollower: Float = 0.01
|
||
var levelHistoryBuf: [[Float]] = []
|
||
var historyWriteIdx: Int = 0
|
||
var wobblePhaseOffset: Double = 0
|
||
var lastTickTime: CFTimeInterval = 0
|
||
var resumeTickCount: Int = 0 // debug: counts first N ticks after resume
|
||
|
||
/// Resize all per-frame buffers when point count changes (rare — settings slider).
|
||
func resizeIfNeeded(count: Int, idleAmplitude: Float) {
|
||
guard count > 0 else { return }
|
||
if targetLevels.count != count {
|
||
targetLevels = [Float](repeating: 0, count: count)
|
||
displayLevels = [Float](repeating: idleAmplitude, count: count)
|
||
idleLevels = [Float](repeating: idleAmplitude, count: count)
|
||
// Pre-fill history ring with idle so depth ghost doesn't start blank
|
||
levelHistoryBuf = [[Float]](
|
||
repeating: [Float](repeating: idleAmplitude, count: count),
|
||
count: MitsuhaVisualizerView.historySize
|
||
)
|
||
historyWriteIdx = 0
|
||
} else if idleLevels.first != idleAmplitude {
|
||
for i in 0..<count { idleLevels[i] = idleAmplitude }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Main Visualizer View
|
||
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
|
||
|
||
// Observe settings only for the enabled gate and config values.
|
||
// The Canvas itself does NOT observe settings — we pass the values it
|
||
// needs as local constants captured at body evaluation time. This means
|
||
// a slider drag in VisualizerSettingsView does NOT invalidate the Canvas
|
||
// on every change — only the outer view re-evaluates, and the Canvas
|
||
// only re-evaluates when isPlaying / isVisible / isAppActive changes.
|
||
@ObservedObject var settings = VisualizerSettings.shared
|
||
@ObservedObject var albumColors = AlbumColorExtractor.shared
|
||
@StateObject private var box = VisualizerLevelBox()
|
||
@State private var isAppActive = true
|
||
|
||
static let historySize = 16
|
||
|
||
/// Active view config — mini player uses its own independent config
|
||
private var config: ViewVisualizerConfig {
|
||
compact ? settings.miniPlayer : settings.nowPlaying
|
||
}
|
||
|
||
private var isRenderingActive: Bool {
|
||
settings.enabled && isPlaying && isAppActive && isVisible
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if settings.enabled {
|
||
// Adaptive FPS: full rate when rendering, 2fps when idle/paused.
|
||
// This prevents the 60fps GPU wakeup that drains battery during pause
|
||
// and stops the render loop from fighting Liquid Glass gesture tracking.
|
||
let targetFPS = isRenderingActive ? settings.effectiveFPS : 2.0
|
||
let tickInterval = 1.0 / max(targetFPS, 1.0)
|
||
|
||
// 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
|
||
Canvas { context, size in
|
||
// Read timeline.date INSIDE Canvas — this is the critical dependency
|
||
// that tells SwiftUI to re-evaluate Canvas on every timeline tick.
|
||
// Reading it outside the Canvas closure (as a let) doesn't establish
|
||
// the Canvas redraw dependency, causing the occasional stall.
|
||
let _ = timeline.date
|
||
box.resizeIfNeeded(count: config.numberOfPoints,
|
||
idleAmplitude: Float(settings.idleAmplitude))
|
||
guard box.displayLevels.count >= 2 else { return }
|
||
|
||
if isRenderingActive {
|
||
let t = CACurrentMediaTime()
|
||
let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
|
||
if box.resumeTickCount < 3 {
|
||
box.resumeTickCount += 1
|
||
DebugLogger.shared.log(
|
||
"TL tick #\(box.resumeTickCount): " +
|
||
"rawCount=\(rawLevels.count) " +
|
||
"rawMax=\(String(format: "%.3f", rawLevels.max() ?? 0)) " +
|
||
"lastTick=\(String(format: "%.1f", box.lastTickTime))",
|
||
category: "VisDebug", level: .debug)
|
||
}
|
||
updateDisplayLevels(newRawLevels: rawLevels, t: t)
|
||
updateWobblePhase(t: t)
|
||
switch config.style {
|
||
case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
|
||
case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels)
|
||
case .line: drawLine(ctx: context, size: size, levels: box.displayLevels)
|
||
case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
|
||
}
|
||
} else {
|
||
// Idle/paused: draw last known state without updating levels
|
||
drawIdleState(ctx: context, size: size)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
|
||
.animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying)
|
||
.onAppear {
|
||
box.resizeIfNeeded(count: config.numberOfPoints,
|
||
idleAmplitude: Float(settings.idleAmplitude))
|
||
if compact, !WaveStateCache.shared.compactLevels.isEmpty {
|
||
let cached = WaveStateCache.shared.compactLevels
|
||
let count = box.displayLevels.count
|
||
for i in 0..<min(cached.count, count) {
|
||
box.displayLevels[i] = cached[i]
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: isPlaying) { _, playing in
|
||
if !playing {
|
||
box.levelHistoryBuf.removeAll(keepingCapacity: true)
|
||
box.historyWriteIdx = 0
|
||
// Do NOT reset peakFollower here. Resetting to 0.01 means the
|
||
// first resume frame gets normFactor=100x (1/0.01), spiking
|
||
// normal levels (~0.5) to 1.0 — the "raise up" on resume.
|
||
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
|
||
// Warm-start peakFollower so normFactor begins around 3x not 100x
|
||
box.peakFollower = 0.3
|
||
box.lastTickTime = 0
|
||
DebugLogger.shared.log(
|
||
"RESUME → isRenderingActive=\(isRenderingActive) " +
|
||
"isAppActive=\(isAppActive) isVisible=\(isVisible) " +
|
||
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count) " +
|
||
"history:\(box.levelHistoryBuf.count)]",
|
||
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
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
|
||
isAppActive = false
|
||
box.levelHistoryBuf.removeAll(keepingCapacity: true)
|
||
box.historyWriteIdx = 0
|
||
}
|
||
}
|
||
|
||
// MARK: - Idle State
|
||
|
||
private func drawIdleState(ctx: GraphicsContext, size: CGSize) {
|
||
guard box.idleLevels.count >= 2 else { return }
|
||
switch config.style {
|
||
case .wave: drawWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
|
||
case .bar: drawBars(ctx: ctx, size: size, levels: box.idleLevels)
|
||
case .line: drawLine(ctx: ctx, size: size, levels: box.idleLevels)
|
||
case .siriWave: drawSiriWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
|
||
}
|
||
}
|
||
|
||
// MARK: - Wobble Phase
|
||
|
||
private func updateWobblePhase(t: Double) {
|
||
if box.lastTickTime > 0 {
|
||
let delta = t - box.lastTickTime
|
||
box.wobblePhaseOffset += min(delta, 0.1)
|
||
}
|
||
box.lastTickTime = t
|
||
}
|
||
|
||
// MARK: - Temporal Smoothing & Log Binning
|
||
|
||
@discardableResult
|
||
private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool {
|
||
let count = config.numberOfPoints
|
||
guard count > 0, !newRawLevels.isEmpty,
|
||
box.targetLevels.count == count else {
|
||
DebugLogger.shared.log(
|
||
"updateDisplayLevels GUARD FAILED: " +
|
||
"count=\(count) rawEmpty=\(newRawLevels.isEmpty) " +
|
||
"targetCount=\(box.targetLevels.count)",
|
||
category: "VisDebug", level: .warning)
|
||
return false
|
||
}
|
||
|
||
let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0)
|
||
let sens = Float(config.sensitivity)
|
||
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
|
||
|
||
// ── Write directly into pre-allocated box.targetLevels ───────────────
|
||
if isPreProcessed {
|
||
if newRawLevels.count == count {
|
||
// Hot path: identical size — single in-place pass, zero allocation
|
||
for i in 0..<count {
|
||
box.targetLevels[i] = min(1.0, newRawLevels[i] * sens)
|
||
}
|
||
} else {
|
||
// Resample via linear interpolation directly into box.targetLevels
|
||
let srcCount = Float(newRawLevels.count - 1)
|
||
let dstCount = Float(max(count - 1, 1))
|
||
for i in 0..<count {
|
||
let srcPos = Float(i) / dstCount * srcCount
|
||
let lo = Int(srcPos)
|
||
let hi = min(lo + 1, newRawLevels.count - 1)
|
||
let frac = srcPos - Float(lo)
|
||
box.targetLevels[i] = min(1.0,
|
||
(newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac) * sens)
|
||
}
|
||
}
|
||
} else {
|
||
// Raw FFT — log binning directly into box.targetLevels
|
||
let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff)
|
||
let mult = Float(settings.baseMultiplier)
|
||
for i in 0..<count {
|
||
let ni = Float(i + 1) / Float(count)
|
||
let centerBin = log10(ni * 9.0 + 1.0) * Float(maxUsefulBin)
|
||
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count))
|
||
let startBin = max(1, Int(centerBin - binWidth * 0.5))
|
||
let endBin = min(maxUsefulBin, Int(centerBin + binWidth * 0.5))
|
||
var sum: Float = 0
|
||
var n = 0
|
||
for j in startBin...endBin where j < newRawLevels.count {
|
||
sum += newRawLevels[j]; n += 1
|
||
}
|
||
box.targetLevels[i] = min(1.0, (n > 0 ? sum / Float(n) : 0) * mult * sens)
|
||
}
|
||
}
|
||
|
||
// ── Dynamic Gain / Peak Follower (in-place on box.targetLevels) ──────
|
||
let smoothFactor = min(Float(settings.viscosity) * 60.0 * dt, 1.0)
|
||
let frameMax = box.targetLevels.max() ?? 0.0
|
||
if settings.dynamicGainEnabled {
|
||
if frameMax > box.peakFollower {
|
||
box.peakFollower += (frameMax - box.peakFollower) * 0.3
|
||
} else {
|
||
box.peakFollower = max(box.peakFollower * pow(0.5, dt), 0.01)
|
||
}
|
||
let normFactor = 1.0 / max(box.peakFollower, 0.01)
|
||
for i in 0..<count {
|
||
box.targetLevels[i] = min(box.targetLevels[i] * normFactor, 1.0)
|
||
}
|
||
} else {
|
||
box.peakFollower = max(box.peakFollower, frameMax)
|
||
}
|
||
|
||
// ── Exponential smooth into displayLevels (in-place) ─────────────────
|
||
for i in 0..<count {
|
||
box.displayLevels[i] += (box.targetLevels[i] - box.displayLevels[i]) * smoothFactor
|
||
}
|
||
|
||
// ── Ring buffer: copy in-place into pre-allocated history slot ────────
|
||
// Pre-allocated slots mean no COW trigger on assignment.
|
||
if box.levelHistoryBuf.count < MitsuhaVisualizerView.historySize {
|
||
box.levelHistoryBuf.append(box.displayLevels)
|
||
} else {
|
||
let idx = box.historyWriteIdx
|
||
if box.levelHistoryBuf[idx].count == count {
|
||
for i in 0..<count {
|
||
box.levelHistoryBuf[idx][i] = box.displayLevels[i]
|
||
}
|
||
} else {
|
||
box.levelHistoryBuf[idx] = box.displayLevels
|
||
}
|
||
box.historyWriteIdx = (box.historyWriteIdx + 1) % MitsuhaVisualizerView.historySize
|
||
}
|
||
|
||
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
|
||
return true
|
||
}
|
||
|
||
// MARK: - Colors
|
||
|
||
private var fillColors: [Color] {
|
||
let a = config.alpha
|
||
switch config.colorMode {
|
||
case .dynamic: return [accentColor.opacity(a), accentColor.opacity(a * 0.6)]
|
||
case .albumArt: return [albumColors.primaryColor.opacity(a), albumColors.secondaryColor.opacity(a * 0.7)]
|
||
case .vibrant: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)]
|
||
case .custom: return [config.customColor.opacity(a), config.customColor.opacity(a * 0.6)]
|
||
}
|
||
}
|
||
|
||
private var strokeColor: Color {
|
||
let a = min(1, config.alpha + 0.3)
|
||
switch config.colorMode {
|
||
case .dynamic: return accentColor.opacity(a)
|
||
case .albumArt: return albumColors.primaryColor.opacity(a)
|
||
case .vibrant: return Color.cyan.opacity(a)
|
||
case .custom: return config.customColor.opacity(a)
|
||
}
|
||
}
|
||
|
||
// MARK: - Catmull-Rom Spline (tension 0.3 = heavy liquid surface tension)
|
||
|
||
/// Tension: 0.3 gives rounded, rolling peaks. Lower = tighter. Higher = bouncier.
|
||
private let curveTension: CGFloat = 0.3
|
||
|
||
/// Curve path without initial moveTo — used for fill shapes
|
||
private func smoothCurve(_ points: [CGPoint]) -> Path {
|
||
var p = Path()
|
||
guard points.count >= 2 else { return p }
|
||
for i in 0..<(points.count - 1) {
|
||
let p0 = i > 0 ? points[i - 1] : points[0]
|
||
let p1 = points[i]
|
||
let p2 = points[i + 1]
|
||
let p3 = i < points.count - 2 ? points[i + 2] : p2
|
||
|
||
let cp1 = CGPoint(
|
||
x: p1.x + (p2.x - p0.x) * curveTension,
|
||
y: p1.y + (p2.y - p0.y) * curveTension
|
||
)
|
||
let cp2 = CGPoint(
|
||
x: p2.x - (p3.x - p1.x) * curveTension,
|
||
y: p2.y - (p3.y - p1.y) * curveTension
|
||
)
|
||
p.addCurve(to: p2, control1: cp1, control2: cp2)
|
||
}
|
||
return p
|
||
}
|
||
|
||
/// Full curve path with moveTo — used for strokes
|
||
private func strokeableCurve(_ points: [CGPoint]) -> Path {
|
||
var p = Path()
|
||
guard points.count >= 2 else { return p }
|
||
p.move(to: points[0])
|
||
|
||
for i in 0..<(points.count - 1) {
|
||
let p0 = i > 0 ? points[i - 1] : points[0]
|
||
let p1 = points[i]
|
||
let p2 = points[i + 1]
|
||
let p3 = i < points.count - 2 ? points[i + 2] : p2
|
||
|
||
let cp1 = CGPoint(
|
||
x: p1.x + (p2.x - p0.x) * curveTension,
|
||
y: p1.y + (p2.y - p0.y) * curveTension
|
||
)
|
||
let cp2 = CGPoint(
|
||
x: p2.x - (p3.x - p1.x) * curveTension,
|
||
y: p2.y - (p3.y - p1.y) * curveTension
|
||
)
|
||
p.addCurve(to: p2, control1: cp1, control2: cp2)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// MARK: - Wave
|
||
|
||
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 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)
|
||
|
||
var points = levels.enumerated().map { i, lev -> CGPoint in
|
||
let x = CGFloat(i) * spacing
|
||
let baseAmp = CGFloat(lev)
|
||
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
|
||
let totalAmp = max(idleVal, baseAmp + organicWobble)
|
||
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
|
||
}
|
||
|
||
// ── 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 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))
|
||
depthFill.addLine(to: depthPts[0])
|
||
depthFill.addPath(depthCurve)
|
||
depthFill.addLine(to: CGPoint(x: w, y: h))
|
||
depthFill.closeSubpath()
|
||
ctx.fill(depthFill, with: .color(strokeColor.opacity(ctxDepthOpacity)))
|
||
|
||
// Layer 2: Main fill — gradient from wave crest to true bottom
|
||
let curve = smoothCurve(points)
|
||
var fill = Path()
|
||
fill.move(to: CGPoint(x: 0, y: h))
|
||
fill.addLine(to: points[0])
|
||
fill.addPath(curve)
|
||
fill.addLine(to: CGPoint(x: w, y: h))
|
||
fill.closeSubpath()
|
||
|
||
let isSiri = config.colorMode == .vibrant
|
||
if isSiri {
|
||
ctx.fill(fill, with: .linearGradient(
|
||
Gradient(colors: fillColors),
|
||
startPoint: CGPoint(x: 0, y: 0),
|
||
endPoint: CGPoint(x: w, y: 0)
|
||
))
|
||
} else {
|
||
ctx.fill(fill, with: .linearGradient(
|
||
Gradient(colors: fillColors),
|
||
startPoint: CGPoint(x: w / 2, y: baseline - h * 0.3),
|
||
endPoint: CGPoint(x: w / 2, y: h)
|
||
))
|
||
}
|
||
|
||
// Layer 3: Stroke line
|
||
ctx.stroke(strokeableCurve(points), with: .color(strokeColor), lineWidth: CGFloat(settings.waveStrokeThickness))
|
||
}
|
||
|
||
// MARK: - Bars
|
||
|
||
private func drawBars(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
|
||
let w = size.width
|
||
let h = size.height
|
||
let count = levels.count
|
||
guard count > 0 else { return }
|
||
|
||
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 gap = CGFloat(settings.barSpacing)
|
||
let cr = CGFloat(settings.barCornerRadius)
|
||
let totalGapSpace = gap * CGFloat(count - 1)
|
||
let barW = max(1, (w - totalGapSpace) / CGFloat(count))
|
||
let isSiri = config.colorMode == .vibrant
|
||
// Hoist fillColors outside loop — was allocating [Color] count times per frame
|
||
let colors = fillColors
|
||
|
||
for i in 0..<count {
|
||
let amp = CGFloat(levels[i])
|
||
let barH = max(compact ? 2 : 4, amp * h * ampScale)
|
||
let x = CGFloat(i) * (barW + gap)
|
||
let barTop = baseline - barH
|
||
let rect = CGRect(x: x, y: barTop, width: barW, height: h - barTop)
|
||
let path = Path(roundedRect: rect, cornerRadius: cr)
|
||
let startPoint = isSiri ? CGPoint(x: 0, y: 0) : CGPoint(x: x, y: barTop)
|
||
let endPoint = isSiri ? CGPoint(x: w, y: 0) : CGPoint(x: x, y: h)
|
||
ctx.fill(path, with: .linearGradient(
|
||
Gradient(colors: colors),
|
||
startPoint: startPoint,
|
||
endPoint: endPoint
|
||
))
|
||
}
|
||
}
|
||
|
||
// MARK: - Line
|
||
|
||
private func drawLine(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
|
||
let w = size.width
|
||
let h = size.height
|
||
guard levels.count >= 2 else { return }
|
||
let spacing = w / CGFloat(levels.count - 1)
|
||
let thick = CGFloat(settings.lineThickness)
|
||
let isSiri = config.colorMode == .vibrant
|
||
|
||
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
|
||
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
|
||
let centerY = compact ? h * 0.5 : h - baseLift - offset
|
||
let ampMult: CGFloat = compact ? CGFloat(settings.miniAmplitude) * 0.5 : CGFloat(settings.npAmplitude) * 0.55
|
||
|
||
let points = levels.enumerated().map { i, lev -> CGPoint in
|
||
let dir: CGFloat = i % 2 == 0 ? -1 : 1
|
||
let amp = CGFloat(lev) * (h * ampMult)
|
||
return CGPoint(x: CGFloat(i) * spacing, y: centerY + (dir * amp))
|
||
}
|
||
|
||
let curve = strokeableCurve(points)
|
||
let strokeStyle = StrokeStyle(lineWidth: thick, lineCap: .round, lineJoin: .round)
|
||
|
||
// Glow
|
||
ctx.stroke(curve, with: .color(strokeColor.opacity(0.3)), style: StrokeStyle(lineWidth: thick + 6, lineCap: .round, lineJoin: .round))
|
||
|
||
// Core line
|
||
if isSiri {
|
||
ctx.stroke(curve, with: .linearGradient(
|
||
Gradient(colors: fillColors),
|
||
startPoint: CGPoint(x: 0, y: centerY),
|
||
endPoint: CGPoint(x: w, y: centerY)
|
||
), style: strokeStyle)
|
||
} else {
|
||
ctx.stroke(curve, with: .color(strokeColor), style: strokeStyle)
|
||
}
|
||
}
|
||
|
||
// MARK: - Siri Wave
|
||
/// Authentic multi-layer Siri waveform.
|
||
///
|
||
/// Five stroked paths are drawn over the same level data. Each layer has:
|
||
/// - A distinct phase offset so the peaks weave past each other organically.
|
||
/// - A distinct amplitude multiplier (some negative = inverted) creating the
|
||
/// crossing / figure-eight structure visible in the original Mitsuha Infinity.
|
||
/// - A sine-window (bell-curve) attenuation across the width: the wave is forced
|
||
/// to zero at both edges and reaches full amplitude only in the centre.
|
||
/// Mathematically: attenuation(x) = sin(π * x/w) which is 0 at x=0, peaks at 1
|
||
/// at x=w/2, and returns to 0 at x=w.
|
||
/// - `plusLighter` blend mode so where two bright lines overlap they add their
|
||
/// luminance, creating glowing intersection nodes like the original.
|
||
private func drawSiriWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
|
||
let w = size.width
|
||
let h = size.height
|
||
guard levels.count >= 2 else { return }
|
||
|
||
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
|
||
let waveOffset = compact ? 0 : CGFloat(settings.waveOffsetTop)
|
||
let centerY = compact ? h * 0.5 : h - baseLift - waveOffset
|
||
let baseAmp: CGFloat = compact
|
||
? CGFloat(settings.miniAmplitude) * h * 0.4
|
||
: CGFloat(settings.npAmplitude) * h * 0.38
|
||
|
||
// Five layers: (phase offset in radians, amplitude multiplier, opacity, stroke width)
|
||
let layers: [(phase: Double, ampMult: CGFloat, opacity: Double, width: CGFloat)] = [
|
||
(phase: 0.0, ampMult: 1.00, opacity: 0.90, width: 2.5), // core
|
||
(phase: .pi * 0.35, ampMult: 0.60, opacity: 0.65, width: 1.8), // support A
|
||
(phase: .pi * 0.70, ampMult: 0.30, opacity: 0.50, width: 1.4), // support B
|
||
(phase: .pi * 1.10, ampMult: -0.40, opacity: 0.45, width: 1.2), // inverted A
|
||
(phase: .pi * 1.55, ampMult: -0.20, opacity: 0.30, width: 1.0), // inverted B
|
||
]
|
||
|
||
let count = levels.count
|
||
let spacing = w / CGFloat(count - 1)
|
||
|
||
// Build siri colour gradient once
|
||
let siriColors: [Color] = [.pink, .purple, .cyan, .green, .pink]
|
||
let useVibrant = config.colorMode == .vibrant
|
||
let a = config.alpha
|
||
|
||
for layer in layers {
|
||
var points: [CGPoint] = []
|
||
points.reserveCapacity(count)
|
||
|
||
for i in 0..<count {
|
||
let x = CGFloat(i) * spacing
|
||
let nx = x / w // normalised 0→1
|
||
|
||
// Sine-window attenuation — zero at edges, 1 at centre
|
||
let attenuation = CGFloat(sin(.pi * nx))
|
||
|
||
// Sample level at this x with per-layer phase offset applied as a
|
||
// time warp: the effective sample position shifts along the level array
|
||
// by (phase / 2π) * count, wrapped with linear interpolation.
|
||
let shift = layer.phase / (2 * .pi) * Double(count - 1)
|
||
let rawPos = Double(i) + shift
|
||
let lo = Int(rawPos) % count
|
||
let hi = (lo + 1) % count
|
||
let frac = CGFloat(rawPos - floor(rawPos))
|
||
let sampledLevel = CGFloat(levels[lo]) * (1 - frac) + CGFloat(levels[hi]) * frac
|
||
|
||
// Organic wobble using continuous time + per-layer phase
|
||
let wobble = CGFloat(sin(continuousTime * 3.0 + Double(i) * 0.8 + layer.phase) * 0.03)
|
||
let totalAmp = (sampledLevel + wobble) * layer.ampMult * attenuation * baseAmp
|
||
|
||
points.append(CGPoint(x: x, y: centerY - totalAmp))
|
||
}
|
||
|
||
let curve = strokeableCurve(points)
|
||
let style = StrokeStyle(lineWidth: layer.width, lineCap: .round, lineJoin: .round)
|
||
|
||
// Draw with plusLighter blend mode for additive glow at intersections
|
||
ctx.drawLayer { layerCtx in
|
||
layerCtx.blendMode = .plusLighter
|
||
if useVibrant {
|
||
layerCtx.stroke(curve, with: .linearGradient(
|
||
Gradient(colors: siriColors.map { $0.opacity(layer.opacity * a) }),
|
||
startPoint: .zero,
|
||
endPoint: CGPoint(x: w, y: 0)
|
||
), style: style)
|
||
} else {
|
||
let c: Color
|
||
switch config.colorMode {
|
||
case .albumArt: c = AlbumColorExtractor.shared.primaryColor
|
||
case .custom: c = config.customColor
|
||
default: c = accentColor
|
||
}
|
||
layerCtx.stroke(curve, with: .color(c.opacity(layer.opacity * a)), style: style)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Compact Visualizer (for mini player)
|
||
struct CompactVisualizerView: View {
|
||
let isPlaying: Bool
|
||
var isSongLoaded: Bool = true
|
||
var songId: String? = nil
|
||
let accentColor: Color
|
||
let height: CGFloat
|
||
|
||
var body: some View {
|
||
MitsuhaVisualizerView(
|
||
isPlaying: isPlaying,
|
||
isSongLoaded: isSongLoaded,
|
||
songId: songId,
|
||
accentColor: accentColor,
|
||
compact: true
|
||
)
|
||
.frame(height: height)
|
||
}
|
||
}
|
||
|
||
// MARK: - Visualizer Settings View
|
||
struct VisualizerSettingsView: View {
|
||
@ObservedObject var settings = VisualizerSettings.shared
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var isCapturingTap = false
|
||
@State private var showShareSheet = false
|
||
@State private var capturedFileURL: URL?
|
||
|
||
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
// ── Master toggles ───────────────────────────────────────────
|
||
Section {
|
||
Toggle("Enabled", isOn: $settings.enabled).tint(pink)
|
||
Toggle("Now Playing Screen", isOn: $settings.nowPlayingEnabled).tint(pink).disabled(!settings.enabled)
|
||
Toggle("Mini Player", isOn: $settings.miniPlayerEnabled).tint(pink).disabled(!settings.enabled)
|
||
} header: { Text("VISUALIZER") } footer: {
|
||
Text("Master toggle disables all visualizers. Individual toggles control each location.")
|
||
}
|
||
|
||
// ── Per-view independent configs ─────────────────────────────
|
||
Section {
|
||
NavigationLink {
|
||
ViewConfigSettingsView(
|
||
title: "Now Playing",
|
||
config: $settings.nowPlaying,
|
||
amplitude: $settings.npAmplitude,
|
||
baseLift: $settings.npBaseLift,
|
||
waveOffset: $settings.waveOffsetTop,
|
||
depthOffset: $settings.depthOffset,
|
||
depthOpacity: $settings.depthOpacity,
|
||
idleAmplitude: $settings.idleAmplitude,
|
||
heightPct: $settings.nowPlayingHeightPct,
|
||
isCompact: false
|
||
)
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: "play.rectangle.fill").foregroundColor(pink).frame(width: 28)
|
||
Text("Now Playing")
|
||
Spacer()
|
||
Text(settings.nowPlaying.style.rawValue)
|
||
.font(.caption).foregroundColor(.gray)
|
||
}
|
||
}
|
||
NavigationLink {
|
||
ViewConfigSettingsView(
|
||
title: "Mini Player",
|
||
config: $settings.miniPlayer,
|
||
amplitude: $settings.miniAmplitude,
|
||
baseLift: .constant(0),
|
||
waveOffset: .constant(0),
|
||
depthOffset: $settings.miniDepthOffset,
|
||
depthOpacity: $settings.miniDepthOpacity,
|
||
idleAmplitude: $settings.miniIdleAmplitude,
|
||
heightPct: .constant(0),
|
||
isCompact: true
|
||
)
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: "rectangle.bottomhalf.filled").foregroundColor(pink).frame(width: 28)
|
||
Text("Mini Player")
|
||
Spacer()
|
||
Text(settings.miniPlayer.style.rawValue)
|
||
.font(.caption).foregroundColor(.gray)
|
||
}
|
||
}
|
||
} header: { Text("PER-VIEW SETTINGS") } footer: {
|
||
Text("Each view has its own style, color, point count, sensitivity, amplitude, and depth. Changes in one view never affect the other.")
|
||
}
|
||
|
||
// ── Style-specific global params ─────────────────────────────
|
||
Section {
|
||
sliderRowDouble("Wave stroke", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f")
|
||
sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f")
|
||
sliderRowDouble("Bar radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f")
|
||
sliderRowDouble("Line thickness",value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f")
|
||
} header: { Text("STYLE GLOBALS") } footer: {
|
||
Text("These shape parameters apply globally to their respective styles across both views.")
|
||
}
|
||
|
||
// ── Shared physics ────────────────────────────────────────────
|
||
Section {
|
||
sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.01, format: "%.2f")
|
||
sliderRow( "Frequency cutoff",value: $settings.frequencyCutoff, range: 40...150, step: 5)
|
||
sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 5.0...50.0, step: 1.0, format: "%.0f")
|
||
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 PHYSICS") } footer: {
|
||
Text("Viscosity: 0.10–0.20 = heavy ocean swell. 0.4+ = snappy EQ. Base Multiplier: 15–25 for most music. Dynamic Gain normalises amplitude across loud and quiet tracks.")
|
||
}
|
||
|
||
// ── Presets ───────────────────────────────────────────────────
|
||
Section {
|
||
Button(action: applyDeepOcean) {
|
||
HStack {
|
||
Image(systemName: "water.waves").foregroundColor(.cyan)
|
||
VStack(alignment: .leading) {
|
||
Text("Deep Ocean Swell").foregroundColor(.white)
|
||
Text("Slow, massive rolling waves").font(.caption2).foregroundColor(.gray)
|
||
}
|
||
}
|
||
}
|
||
Button(action: applyReactiveEQ) {
|
||
HStack {
|
||
Image(systemName: "waveform").foregroundColor(.green)
|
||
VStack(alignment: .leading) {
|
||
Text("Reactive EQ").foregroundColor(.white)
|
||
Text("Detailed, fast response").font(.caption2).foregroundColor(.gray)
|
||
}
|
||
}
|
||
}
|
||
Button(action: applySubtleAmbient) {
|
||
HStack {
|
||
Image(systemName: "moonphase.waning.crescent").foregroundColor(.purple)
|
||
VStack(alignment: .leading) {
|
||
Text("Subtle Ambient").foregroundColor(.white)
|
||
Text("Gentle, transparent shimmer").font(.caption2).foregroundColor(.gray)
|
||
}
|
||
}
|
||
}
|
||
Button(action: applyHeavyMercury) {
|
||
HStack {
|
||
Image(systemName: "drop.fill").foregroundColor(.gray)
|
||
VStack(alignment: .leading) {
|
||
Text("Heavy Liquid Mercury").foregroundColor(.white)
|
||
Text("Dense, weighty metallic flow").font(.caption2).foregroundColor(.gray)
|
||
}
|
||
}
|
||
}
|
||
} header: { Text("PRESETS") } footer: {
|
||
Text("Presets apply to the Now Playing view. Fine-tune in Per-View Settings afterwards.")
|
||
}
|
||
|
||
Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } }
|
||
|
||
// ── Debug: Audio Tap Capture ─────────────────────────────────
|
||
Section {
|
||
if isCapturingTap {
|
||
HStack {
|
||
ProgressView().tint(pink)
|
||
Text("Capturing 5s of audio tap…")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(.gray)
|
||
.padding(.leading, 8)
|
||
}
|
||
} else {
|
||
Button(action: startTapCapture) {
|
||
HStack {
|
||
Image(systemName: "waveform.badge.mic")
|
||
.foregroundColor(.orange)
|
||
VStack(alignment: .leading) {
|
||
Text("Capture Audio Tap").foregroundColor(.white)
|
||
Text("Records 5s of raw tap output as WAV")
|
||
.font(.caption2).foregroundColor(.gray)
|
||
}
|
||
}
|
||
}
|
||
.disabled(!AudioPlayer.shared.isRadioStream)
|
||
}
|
||
|
||
if let fmt = AudioTapProcessor.shared.sourceFormat {
|
||
HStack {
|
||
Text("Tap format")
|
||
.foregroundColor(.gray)
|
||
Spacer()
|
||
Text("\(Int(fmt.sampleRate))Hz \(fmt.channelCount)ch")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.white)
|
||
}
|
||
.font(.system(size: 13))
|
||
}
|
||
} header: { Text("DEBUG: AUDIO TAP") } footer: {
|
||
Text("Captures raw PCM from MTAudioProcessingTap as a playable WAV file. Only active during radio playback. Use to verify the tap is receiving real audio data.")
|
||
}
|
||
}
|
||
.navigationTitle("Visualizer")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button("Done") { dismiss() }.foregroundColor(pink)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showShareSheet) {
|
||
if let url = capturedFileURL {
|
||
ShareSheet(items: [url])
|
||
}
|
||
}
|
||
.onAppear {
|
||
// Bug 12 fix: reset stuck spinner if capture was interrupted by background
|
||
if isCapturingTap && !AudioTapProcessor.shared.debugDumpEnabled {
|
||
isCapturingTap = false
|
||
}
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: AudioTapProcessor.captureCompleteNotification)) { note in
|
||
// Bug 11 fix: notification-based completion — works even if view was dismissed and re-opened
|
||
isCapturingTap = false
|
||
if let url = note.userInfo?["url"] as? URL {
|
||
capturedFileURL = url
|
||
showShareSheet = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Tap Capture
|
||
|
||
private func startTapCapture() {
|
||
isCapturingTap = true
|
||
AudioTapProcessor.shared.startDebugDump()
|
||
}
|
||
|
||
// MARK: - Presets
|
||
|
||
private func applyDeepOcean() {
|
||
settings.nowPlaying.wavePoints = 6
|
||
settings.viscosity = 0.12; settings.baseMultiplier = 22; settings.frequencyCutoff = 50
|
||
settings.npAmplitude = 0.75; settings.depthOffset = 30
|
||
settings.nowPlaying.waveSensitivity = 0.8
|
||
}
|
||
private func applyReactiveEQ() {
|
||
settings.nowPlaying.barPoints = 18; settings.nowPlaying.style = .bar
|
||
settings.viscosity = 0.6; settings.baseMultiplier = 20; settings.frequencyCutoff = 120
|
||
settings.npAmplitude = 0.5; settings.depthOffset = 5
|
||
settings.nowPlaying.barSensitivity = 1.4
|
||
}
|
||
private func applySubtleAmbient() {
|
||
settings.nowPlaying.wavePoints = 8; settings.nowPlaying.style = .wave
|
||
settings.viscosity = 0.18; settings.baseMultiplier = 15; settings.idleAmplitude = 0.04
|
||
settings.npAmplitude = 0.25; settings.nowPlaying.alpha = 0.3
|
||
settings.nowPlaying.waveSensitivity = 0.7
|
||
}
|
||
private func applyHeavyMercury() {
|
||
settings.nowPlaying.wavePoints = 10; settings.nowPlaying.style = .wave
|
||
settings.viscosity = 0.12; settings.baseMultiplier = 60
|
||
settings.npAmplitude = 0.6; settings.depthOffset = 20
|
||
settings.nowPlaying.waveSensitivity = 2.5
|
||
}
|
||
|
||
// MARK: - Slider helpers
|
||
|
||
func sliderRow(_ label: String, value: Binding<Int>, range: ClosedRange<Int>, step: Int) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(label).font(.body)
|
||
HStack {
|
||
Slider(value: Binding(get: { Double(value.wrappedValue) }, set: { value.wrappedValue = Int($0) }),
|
||
in: Double(range.lowerBound)...Double(range.upperBound), step: Double(step)).tint(pink)
|
||
Text("\(value.wrappedValue)").foregroundColor(.gray).frame(width: 44, alignment: .trailing)
|
||
}
|
||
}
|
||
}
|
||
|
||
func sliderRowDouble(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(label).font(.body)
|
||
HStack {
|
||
Slider(value: value, in: range, step: step).tint(pink)
|
||
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 44, alignment: .trailing)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func resetDefaults() {
|
||
settings.nowPlaying = .nowPlayingDefault
|
||
settings.miniPlayer = .miniPlayerDefault
|
||
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
|
||
settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true
|
||
settings.waveStrokeThickness = 1.5; settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
|
||
settings.viscosity = 0.17; settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0
|
||
settings.waveOffsetTop = 0; settings.nowPlayingHeightPct = 0.50
|
||
settings.npAmplitude = 0.50; settings.npBaseLift = 130.0
|
||
settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03
|
||
settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5
|
||
settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
|
||
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
|
||
}
|
||
}
|
||
|
||
// MARK: - Per-View Config Settings (Now Playing & Mini Player share this)
|
||
struct ViewConfigSettingsView: View {
|
||
let title: String
|
||
@Binding var config: ViewVisualizerConfig
|
||
@Binding var amplitude: Double
|
||
@Binding var baseLift: Double
|
||
@Binding var waveOffset: Double
|
||
@Binding var depthOffset: Double
|
||
@Binding var depthOpacity: Double
|
||
@Binding var idleAmplitude:Double
|
||
@Binding var heightPct: Double
|
||
var isCompact: Bool
|
||
|
||
@ObservedObject private var settings = VisualizerSettings.shared
|
||
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||
|
||
var body: some View {
|
||
Form {
|
||
// ── Style ─────────────────────────────────────────────────────────
|
||
Section {
|
||
Picker("Style", selection: $config.style) {
|
||
ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) }
|
||
}.pickerStyle(.segmented)
|
||
|
||
// Per-style point count — independent, not shared with other style
|
||
sd("Points (\(config.style.rawValue))",
|
||
value: Binding(
|
||
get: { Double(config.numberOfPoints) },
|
||
set: { config.numberOfPoints = Int($0) }
|
||
),
|
||
range: 4...24, step: 1, format: "%.0f")
|
||
|
||
// Per-style sensitivity
|
||
sd("Sensitivity (\(config.style.rawValue))",
|
||
value: Binding(
|
||
get: { config.sensitivity },
|
||
set: { config.sensitivity = $0 }
|
||
),
|
||
range: 0.1...2.0, step: 0.1, format: "%.1f")
|
||
|
||
} header: { Text("STYLE") } footer: {
|
||
Text("Each style remembers its own point count and sensitivity independently. Switching styles never overwrites another style's values.")
|
||
}
|
||
|
||
// ── Color ─────────────────────────────────────────────────────────
|
||
Section {
|
||
Picker("Color", selection: $config.colorMode) {
|
||
ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
|
||
}.pickerStyle(.segmented)
|
||
sd("Alpha", value: $config.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f")
|
||
if config.colorMode == .custom {
|
||
ColorPicker("Wave Color", selection: Binding(
|
||
get: { config.customColor },
|
||
set: { c in
|
||
if let components = UIColor(c).cgColor.components, components.count >= 3 {
|
||
config.customColorR = Double(components[0])
|
||
config.customColorG = Double(components[1])
|
||
config.customColorB = Double(components[2])
|
||
}
|
||
}
|
||
))
|
||
}
|
||
} header: { Text("COLOR") }
|
||
|
||
// ── Amplitude & Layout ────────────────────────────────────────────
|
||
Section {
|
||
if !isCompact {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Screen height %").font(.body)
|
||
HStack {
|
||
Slider(value: $heightPct, in: 0.2...0.8, step: 0.05).tint(pink)
|
||
Text("\(Int(heightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing)
|
||
}
|
||
}
|
||
}
|
||
sd("Amplitude", value: $amplitude, range: 0.1...1.0, step: 0.05, format: "%.2f")
|
||
if !isCompact {
|
||
sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt")
|
||
sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f")
|
||
}
|
||
} header: { Text("LAYOUT & AMPLITUDE") }
|
||
|
||
// ── Depth & Idle ──────────────────────────────────────────────────
|
||
Section {
|
||
sd("Depth offset", value: $depthOffset, range: 0...50, step: 2, format: "%.0f")
|
||
sd("Depth opacity", value: $depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
|
||
sd("Idle amplitude",value: $idleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f")
|
||
} header: { Text("DEPTH & IDLE") }
|
||
|
||
// ── Preview ───────────────────────────────────────────────────────
|
||
Section {
|
||
ZStack {
|
||
Color(white: isCompact ? 0.12 : 0.0)
|
||
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true,
|
||
accentColor: pink, compact: isCompact)
|
||
.frame(height: isCompact ? settings.miniPlayerHeight : 180)
|
||
.opacity(isCompact ? settings.miniOpacity : 1.0)
|
||
if isCompact {
|
||
HStack(spacing: 12) {
|
||
RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.3)).frame(width: 40, height: 40)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Song Title").font(.system(size: 14, weight: .medium)).foregroundColor(.white)
|
||
Text("Artist").font(.system(size: 12)).foregroundColor(.gray)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "pause.fill").foregroundColor(.white)
|
||
Image(systemName: "forward.fill").foregroundColor(.white)
|
||
}.padding(.horizontal, 12)
|
||
}
|
||
}
|
||
.frame(height: isCompact ? 64 : 180)
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color(white: isCompact ? 0.12 : 0.0))
|
||
} header: { Text("PREVIEW") }
|
||
}
|
||
.navigationTitle(title)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private var previewLevels: [Float] {
|
||
(0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) }
|
||
}
|
||
|
||
private func sd(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(label).font(.body)
|
||
HStack {
|
||
Slider(value: value, in: range, step: step).tint(pink)
|
||
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 52, alignment: .trailing)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Legacy sub-settings views (kept for backward compat, now redirect to unified view)
|
||
struct NowPlayingVisSettingsView: View {
|
||
@ObservedObject var settings: VisualizerSettings
|
||
var body: some View {
|
||
ViewConfigSettingsView(
|
||
title: "Now Playing",
|
||
config: $settings.nowPlaying,
|
||
amplitude: $settings.npAmplitude,
|
||
baseLift: $settings.npBaseLift,
|
||
waveOffset: $settings.waveOffsetTop,
|
||
depthOffset: $settings.depthOffset,
|
||
depthOpacity: $settings.depthOpacity,
|
||
idleAmplitude: $settings.idleAmplitude,
|
||
heightPct: $settings.nowPlayingHeightPct,
|
||
isCompact: false
|
||
)
|
||
}
|
||
}
|
||
|
||
struct MiniPlayerVisSettingsView: View {
|
||
@ObservedObject var settings: VisualizerSettings
|
||
var body: some View {
|
||
ViewConfigSettingsView(
|
||
title: "Mini Player",
|
||
config: $settings.miniPlayer,
|
||
amplitude: $settings.miniAmplitude,
|
||
baseLift: .constant(0),
|
||
waveOffset: .constant(0),
|
||
depthOffset: $settings.miniDepthOffset,
|
||
depthOpacity: $settings.miniDepthOpacity,
|
||
idleAmplitude: $settings.miniIdleAmplitude,
|
||
heightPct: .constant(0),
|
||
isCompact: true
|
||
)
|
||
}
|
||
}
|