Add Psytrance Visualizer macOS app with Metal rendering
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
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user