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:
Claude
2025-12-22 21:36:45 +00:00
parent b607a9cd8a
commit a22c238dc4
29 changed files with 4780 additions and 0 deletions
@@ -0,0 +1,140 @@
//
// ColorPalette.swift
// PsytranceVisualizer
//
// Psytrance color palette for UI and shaders
//
import AppKit
import simd
/// Psytrance-inspired neon/UV color palette
struct PsytranceColors {
// MARK: - Primary Colors (NSColor for UI)
/// Neon Magenta - Primary accent color
static let neonMagenta = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 1.0)
/// Neon Cyan - Secondary accent color
static let neonCyan = NSColor(red: 0.0, green: 1.0, blue: 1.0, alpha: 1.0)
/// Neon Green - High energy accents
static let neonGreen = NSColor(red: 0.224, green: 1.0, blue: 0.078, alpha: 1.0)
/// UV Violet - Deep purple for backgrounds
static let uvViolet = NSColor(red: 0.482, green: 0.0, blue: 1.0, alpha: 1.0)
/// Deep Black - Background color
static let background = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
/// Dark Purple - Alternative background
static let darkPurple = NSColor(red: 0.1, green: 0.0, blue: 0.15, alpha: 1.0)
/// Hot Pink - Peak indicators
static let hotPink = NSColor(red: 1.0, green: 0.2, blue: 0.6, alpha: 1.0)
/// Electric Blue - UI elements
static let electricBlue = NSColor(red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0)
// MARK: - SIMD3<Float> Colors (for Metal shaders)
struct Metal {
static let neonMagenta = SIMD3<Float>(1.0, 0.0, 1.0)
static let neonCyan = SIMD3<Float>(0.0, 1.0, 1.0)
static let neonGreen = SIMD3<Float>(0.224, 1.0, 0.078)
static let uvViolet = SIMD3<Float>(0.482, 0.0, 1.0)
static let background = SIMD3<Float>(0.0, 0.0, 0.0)
static let darkPurple = SIMD3<Float>(0.1, 0.0, 0.15)
static let hotPink = SIMD3<Float>(1.0, 0.2, 0.6)
static let electricBlue = SIMD3<Float>(0.0, 0.5, 1.0)
/// Array of all palette colors for cycling
static let palette: [SIMD3<Float>] = [
neonMagenta,
neonCyan,
neonGreen,
uvViolet,
hotPink,
electricBlue
]
/// Get color from palette by index (wraps around)
static func color(at index: Int) -> SIMD3<Float> {
palette[index % palette.count]
}
/// Interpolate between two colors
static func lerp(_ a: SIMD3<Float>, _ b: SIMD3<Float>, t: Float) -> SIMD3<Float> {
a + (b - a) * t
}
/// Get rainbow color from normalized value (0-1)
static func rainbow(_ t: Float) -> SIMD3<Float> {
let index = Int(t * Float(palette.count))
let nextIndex = (index + 1) % palette.count
let localT = (t * Float(palette.count)) - Float(index)
return lerp(palette[index % palette.count], palette[nextIndex], t: localT)
}
}
// MARK: - Gradient Helpers
/// Creates a gradient from UV Violet through Magenta to Cyan
static var spectrumGradient: NSGradient? {
NSGradient(colors: [uvViolet, neonMagenta, hotPink, neonCyan, neonGreen])
}
/// Creates a gradient for heat maps (low to high energy)
static var heatmapGradient: NSGradient? {
NSGradient(colors: [
NSColor(red: 0.1, green: 0.0, blue: 0.2, alpha: 1.0), // Dark purple (low)
uvViolet,
neonMagenta,
hotPink,
neonCyan,
neonGreen,
NSColor.white // White (peak)
])
}
// MARK: - UI Theme Colors
struct UI {
static let panelBackground = NSColor(red: 0.05, green: 0.02, blue: 0.08, alpha: 0.9)
static let buttonBackground = NSColor(red: 0.15, green: 0.05, blue: 0.2, alpha: 1.0)
static let buttonHighlight = neonMagenta.withAlphaComponent(0.8)
static let sliderTint = neonCyan
static let labelText = NSColor.white
static let secondaryText = NSColor(white: 0.7, alpha: 1.0)
static let border = uvViolet.withAlphaComponent(0.5)
}
}
// MARK: - NSColor Extension
extension NSColor {
/// Converts NSColor to SIMD3<Float> for Metal
var simd3: SIMD3<Float> {
guard let rgb = usingColorSpace(.deviceRGB) else {
return SIMD3<Float>(0, 0, 0)
}
return SIMD3<Float>(
Float(rgb.redComponent),
Float(rgb.greenComponent),
Float(rgb.blueComponent)
)
}
/// Converts NSColor to SIMD4<Float> for Metal (with alpha)
var simd4: SIMD4<Float> {
guard let rgb = usingColorSpace(.deviceRGB) else {
return SIMD4<Float>(0, 0, 0, 1)
}
return SIMD4<Float>(
Float(rgb.redComponent),
Float(rgb.greenComponent),
Float(rgb.blueComponent),
Float(rgb.alphaComponent)
)
}
}
@@ -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
}
}