a22c238dc4
A complete audio-reactive visualizer for psytrance music featuring: Audio Analysis (DSPEngine): - FFT spectrum analysis via Accelerate/vDSP - 64-band Mel spectrogram - Sub-bass energy extraction (<100Hz) - Automatic sidechain pump detection - Harmonic-to-Noise ratio (HNR) calculation - Peak/transient detection 8 Visualization Modes (Metal Shaders): 1. FFT Classic - Frequency spectrum bars with glow 2. Mel Spectrogram - Waterfall display 3. Sub-Bass - Pulsating rings 4. Sidechain Pump - Breathing zoom effect 5. Harmonic/Noise - Geometric vs chaotic particles 6. Mandelbrot - Audio-reactive fractal zoom 7. Tunnel Warp - Infinite tunnel with distortion 8. DMT Geometry - Sacred geometry patterns Features: - Selectable audio input device (BlackHole support) - Configurable buffer size (512/1024) - Reactivity slider for visual intensity - Auto-hiding control panel - Fullscreen support with keyboard shortcuts (1-8, F, ESC) - Persistent settings via UserDefaults - Psytrance-inspired neon/UV color palette
186 lines
5.3 KiB
Swift
186 lines
5.3 KiB
Swift
//
|
|
// SettingsManager.swift
|
|
// PsytranceVisualizer
|
|
//
|
|
// Handles loading and saving of application settings
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
|
|
/// Manages persistent storage and retrieval of application settings
|
|
final class SettingsManager: ObservableObject {
|
|
// MARK: - Singleton
|
|
|
|
static let shared = SettingsManager()
|
|
|
|
// MARK: - Published Properties
|
|
|
|
@Published private(set) var settings: AppSettings
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private let settingsKey = "PsytranceVisualizerSettings"
|
|
private let fileManager = FileManager.default
|
|
private var saveWorkItem: DispatchWorkItem?
|
|
|
|
// MARK: - Initialization
|
|
|
|
private init() {
|
|
self.settings = SettingsManager.loadSettings()
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Updates settings and triggers auto-save
|
|
func updateSettings(_ update: (inout AppSettings) -> Void) {
|
|
update(&settings)
|
|
settings.validate()
|
|
scheduleSave()
|
|
}
|
|
|
|
/// Updates selected audio device
|
|
func setAudioDevice(uid: String?) {
|
|
updateSettings { $0.selectedAudioDeviceUID = uid }
|
|
}
|
|
|
|
/// Updates buffer size
|
|
func setBufferSize(_ size: Int) {
|
|
guard AppSettings.availableBufferSizes.contains(size) else { return }
|
|
updateSettings { $0.bufferSize = size }
|
|
}
|
|
|
|
/// Updates visualization mode
|
|
func setVisualizationMode(_ mode: VisualizationMode) {
|
|
updateSettings { $0.lastVisualizationMode = mode.rawValue }
|
|
}
|
|
|
|
/// Updates reactivity
|
|
func setReactivity(_ value: Float) {
|
|
updateSettings { $0.reactivity = max(0.0, min(1.0, value)) }
|
|
}
|
|
|
|
/// Updates fullscreen state
|
|
func setFullscreen(_ isFullscreen: Bool) {
|
|
updateSettings { $0.isFullscreen = isFullscreen }
|
|
}
|
|
|
|
/// Updates window frame
|
|
func setWindowFrame(_ frame: CGRect) {
|
|
updateSettings { $0.windowFrame = CodableRect(from: frame) }
|
|
}
|
|
|
|
/// Updates input gain
|
|
func setInputGain(_ gain: Float) {
|
|
updateSettings { $0.inputGain = max(0.0, min(2.0, gain)) }
|
|
}
|
|
|
|
/// Updates FPS display setting
|
|
func setShowFPS(_ show: Bool) {
|
|
updateSettings { $0.showFPS = show }
|
|
}
|
|
|
|
/// Forces immediate save
|
|
func saveNow() {
|
|
saveWorkItem?.cancel()
|
|
performSave()
|
|
}
|
|
|
|
/// Resets to default settings
|
|
func resetToDefaults() {
|
|
settings = .default
|
|
saveNow()
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Schedules a debounced save operation
|
|
private func scheduleSave() {
|
|
saveWorkItem?.cancel()
|
|
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
self?.performSave()
|
|
}
|
|
|
|
saveWorkItem = workItem
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
|
|
}
|
|
|
|
/// Performs the actual save operation
|
|
private func performSave() {
|
|
do {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = .prettyPrinted
|
|
let data = try encoder.encode(settings)
|
|
|
|
// Save to UserDefaults
|
|
UserDefaults.standard.set(data, forKey: settingsKey)
|
|
|
|
// Also save to file for backup
|
|
if let url = settingsFileURL {
|
|
try data.write(to: url)
|
|
}
|
|
|
|
print("[SettingsManager] Settings saved successfully")
|
|
} catch {
|
|
print("[SettingsManager] Failed to save settings: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Loads settings from storage
|
|
private static func loadSettings() -> AppSettings {
|
|
// Try UserDefaults first
|
|
if let data = UserDefaults.standard.data(forKey: "PsytranceVisualizerSettings") {
|
|
do {
|
|
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
|
|
settings.validate()
|
|
print("[SettingsManager] Settings loaded from UserDefaults")
|
|
return settings
|
|
} catch {
|
|
print("[SettingsManager] Failed to decode settings from UserDefaults: \(error)")
|
|
}
|
|
}
|
|
|
|
// Try file backup
|
|
if let url = settingsFileURL,
|
|
let data = try? Data(contentsOf: url) {
|
|
do {
|
|
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
|
|
settings.validate()
|
|
print("[SettingsManager] Settings loaded from file")
|
|
return settings
|
|
} catch {
|
|
print("[SettingsManager] Failed to decode settings from file: \(error)")
|
|
}
|
|
}
|
|
|
|
print("[SettingsManager] Using default settings")
|
|
return .default
|
|
}
|
|
|
|
/// URL for settings file backup
|
|
private static var settingsFileURL: URL? {
|
|
guard let appSupport = FileManager.default.urls(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask
|
|
).first else {
|
|
return nil
|
|
}
|
|
|
|
let appDirectory = appSupport.appendingPathComponent("PsytranceVisualizer")
|
|
|
|
// Create directory if needed
|
|
try? FileManager.default.createDirectory(
|
|
at: appDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
|
|
return appDirectory.appendingPathComponent("settings.json")
|
|
}
|
|
|
|
/// Current visualization mode
|
|
var currentVisualizationMode: VisualizationMode {
|
|
VisualizationMode(rawValue: settings.lastVisualizationMode) ?? .fftClassic
|
|
}
|
|
}
|