Files
Claude a22c238dc4 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
2025-12-22 21:36:45 +00:00

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
}
}