diff --git a/PsytranceVisualizer/App/AppDelegate.swift b/PsytranceVisualizer/App/AppDelegate.swift new file mode 100644 index 0000000..f7cc8e2 --- /dev/null +++ b/PsytranceVisualizer/App/AppDelegate.swift @@ -0,0 +1,120 @@ +// +// AppDelegate.swift +// PsytranceVisualizer +// +// Application delegate handling app lifecycle +// + +import AppKit +import AVFoundation + +/// Application delegate +final class AppDelegate: NSObject, NSApplicationDelegate { + // MARK: - Properties + + private var mainWindowController: MainWindowController? + + // MARK: - App Lifecycle + + func applicationDidFinishLaunching(_ notification: Notification) { + // Request microphone permission + requestMicrophonePermission() + + // Create and show main window + mainWindowController = MainWindowController() + mainWindowController?.showWindow(nil) + mainWindowController?.window?.makeKeyAndOrderFront(nil) + + // Activate the application + NSApp.activate(ignoringOtherApps: true) + + print("[AppDelegate] Application launched") + } + + func applicationWillTerminate(_ notification: Notification) { + // Save settings + SettingsManager.shared.saveNow() + + print("[AppDelegate] Application terminating") + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + + // MARK: - Permissions + + private func requestMicrophonePermission() { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + print("[AppDelegate] Microphone access already authorized") + + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { granted in + if granted { + print("[AppDelegate] Microphone access granted") + } else { + print("[AppDelegate] Microphone access denied") + self.showMicrophonePermissionAlert() + } + } + + case .denied, .restricted: + print("[AppDelegate] Microphone access denied or restricted") + showMicrophonePermissionAlert() + + @unknown default: + break + } + } + + private func showMicrophonePermissionAlert() { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Microphone Access Required" + alert.informativeText = "Psytrance Visualizer needs access to your audio input to visualize music. Please enable microphone access in System Preferences > Security & Privacy > Privacy > Microphone." + alert.alertStyle = .warning + alert.addButton(withTitle: "Open System Preferences") + alert.addButton(withTitle: "Cancel") + + if alert.runModal() == .alertFirstButtonReturn { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") { + NSWorkspace.shared.open(url) + } + } + } + } + + // MARK: - Menu Actions + + @IBAction func showAbout(_ sender: Any) { + let alert = NSAlert() + alert.messageText = "Psytrance Visualizer" + alert.informativeText = """ + An audio-reactive visualizer for psytrance music. + + 8 Visualization Modes: + 1 - FFT Classic + 2 - Mel Spectrogram + 3 - Sub-Bass + 4 - Sidechain Pump + 5 - Harmonic/Noise + 6 - Mandelbrot + 7 - Tunnel Warp + 8 - DMT Geometry + + Keyboard Shortcuts: + 1-8: Switch visualization mode + F: Toggle fullscreen + ESC: Exit fullscreen + + Tip: Use a virtual audio device like BlackHole to route system audio. + """ + alert.alertStyle = .informational + alert.runModal() + } +} diff --git a/PsytranceVisualizer/App/PsytranceVisualizerApp.swift b/PsytranceVisualizer/App/PsytranceVisualizerApp.swift new file mode 100644 index 0000000..5d8fc3d --- /dev/null +++ b/PsytranceVisualizer/App/PsytranceVisualizerApp.swift @@ -0,0 +1,133 @@ +// +// PsytranceVisualizerApp.swift +// PsytranceVisualizer +// +// Main application entry point +// + +import AppKit + +// MARK: - Main Entry Point + +/// Application entry point +@main +struct PsytranceVisualizerApp { + static func main() { + // Create the application + let app = NSApplication.shared + + // Set up the delegate + let delegate = AppDelegate() + app.delegate = delegate + + // Set activation policy + app.setActivationPolicy(.regular) + + // Create the main menu + setupMainMenu() + + // Run the application + app.run() + } + + /// Sets up the application's main menu + private static func setupMainMenu() { + let mainMenu = NSMenu() + + // Application menu + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + + appMenu.addItem(withTitle: "About Psytrance Visualizer", + action: #selector(AppDelegate.showAbout(_:)), + keyEquivalent: "") + + appMenu.addItem(NSMenuItem.separator()) + + appMenu.addItem(withTitle: "Hide Psytrance Visualizer", + action: #selector(NSApplication.hide(_:)), + keyEquivalent: "h") + + let hideOthersItem = appMenu.addItem(withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h") + hideOthersItem.keyEquivalentModifierMask = [.command, .option] + + appMenu.addItem(withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "") + + appMenu.addItem(NSMenuItem.separator()) + + appMenu.addItem(withTitle: "Quit Psytrance Visualizer", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + + // View menu + let viewMenuItem = NSMenuItem() + mainMenu.addItem(viewMenuItem) + + let viewMenu = NSMenu(title: "View") + viewMenuItem.submenu = viewMenu + + viewMenu.addItem(withTitle: "Toggle Fullscreen", + action: #selector(NSWindow.toggleFullScreen(_:)), + keyEquivalent: "f") + + viewMenu.addItem(NSMenuItem.separator()) + + // Visualization mode submenu + let modesMenuItem = NSMenuItem(title: "Visualization Mode", action: nil, keyEquivalent: "") + let modesMenu = NSMenu() + + for mode in VisualizationMode.allCases { + let item = NSMenuItem(title: mode.displayName, + action: nil, + keyEquivalent: mode.shortcut) + item.tag = mode.rawValue + modesMenu.addItem(item) + } + + modesMenuItem.submenu = modesMenu + viewMenu.addItem(modesMenuItem) + + // Window menu + let windowMenuItem = NSMenuItem() + mainMenu.addItem(windowMenuItem) + + let windowMenu = NSMenu(title: "Window") + windowMenuItem.submenu = windowMenu + + windowMenu.addItem(withTitle: "Minimize", + action: #selector(NSWindow.miniaturize(_:)), + keyEquivalent: "m") + + windowMenu.addItem(withTitle: "Zoom", + action: #selector(NSWindow.zoom(_:)), + keyEquivalent: "") + + windowMenu.addItem(NSMenuItem.separator()) + + windowMenu.addItem(withTitle: "Bring All to Front", + action: #selector(NSApplication.arrangeInFront(_:)), + keyEquivalent: "") + + // Help menu + let helpMenuItem = NSMenuItem() + mainMenu.addItem(helpMenuItem) + + let helpMenu = NSMenu(title: "Help") + helpMenuItem.submenu = helpMenu + + helpMenu.addItem(withTitle: "Psytrance Visualizer Help", + action: #selector(AppDelegate.showAbout(_:)), + keyEquivalent: "?") + + NSApp.mainMenu = mainMenu + NSApp.windowsMenu = windowMenu + NSApp.helpMenu = helpMenu + } +} diff --git a/PsytranceVisualizer/Audio/AudioInputManager.swift b/PsytranceVisualizer/Audio/AudioInputManager.swift new file mode 100644 index 0000000..91e54f8 --- /dev/null +++ b/PsytranceVisualizer/Audio/AudioInputManager.swift @@ -0,0 +1,357 @@ +// +// AudioInputManager.swift +// PsytranceVisualizer +// +// Manages audio input devices and captures audio buffers +// + +import AVFoundation +import CoreAudio +import Combine + +/// Represents an audio input device +struct AudioDevice: Identifiable, Hashable { + let id: AudioDeviceID + let uid: String + let name: String + let manufacturer: String + let isInput: Bool + + func hash(into hasher: inout Hasher) { + hasher.combine(uid) + } + + static func == (lhs: AudioDevice, rhs: AudioDevice) -> Bool { + lhs.uid == rhs.uid + } +} + +/// Manages audio input capture using AVAudioEngine +final class AudioInputManager: ObservableObject { + // MARK: - Published Properties + + @Published private(set) var availableDevices: [AudioDevice] = [] + @Published private(set) var selectedDevice: AudioDevice? + @Published private(set) var isRunning = false + @Published private(set) var currentBufferSize: Int = 1024 + + // MARK: - Audio Properties + + private var audioEngine: AVAudioEngine? + private var inputNode: AVAudioInputNode? + private let sampleRate: Double = 44100.0 + + // MARK: - Callbacks + + var onAudioBuffer: ((AVAudioPCMBuffer) -> Void)? + + // MARK: - Private Properties + + private var deviceListenerBlock: AudioObjectPropertyListenerBlock? + private let processingQueue = DispatchQueue(label: "com.psytrance.audio", qos: .userInteractive) + + // MARK: - Initialization + + init() { + refreshDeviceList() + setupDeviceChangeListener() + } + + deinit { + stop() + removeDeviceChangeListener() + } + + // MARK: - Public Methods + + /// Returns list of available audio input devices + func getAvailableInputDevices() -> [AudioDevice] { + return availableDevices + } + + /// Refreshes the list of available audio input devices + func refreshDeviceList() { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var dataSize: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize + ) + + guard status == noErr else { + print("[AudioInputManager] Failed to get device list size: \(status)") + return + } + + let deviceCount = Int(dataSize) / MemoryLayout.size + var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount) + + status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize, + &deviceIDs + ) + + guard status == noErr else { + print("[AudioInputManager] Failed to get device list: \(status)") + return + } + + var devices: [AudioDevice] = [] + + for deviceID in deviceIDs { + if let device = getDeviceInfo(deviceID: deviceID), device.isInput { + devices.append(device) + } + } + + DispatchQueue.main.async { + self.availableDevices = devices + print("[AudioInputManager] Found \(devices.count) input devices") + } + } + + /// Selects an audio input device by UID + func selectDevice(uid: String) { + guard let device = availableDevices.first(where: { $0.uid == uid }) else { + print("[AudioInputManager] Device not found: \(uid)") + return + } + + let wasRunning = isRunning + if wasRunning { + stop() + } + + selectedDevice = device + setSystemInputDevice(deviceID: device.id) + + if wasRunning { + start() + } + + print("[AudioInputManager] Selected device: \(device.name)") + } + + /// Sets the buffer size (512 or 1024) + func setBufferSize(_ size: Int) { + guard [512, 1024].contains(size) else { + print("[AudioInputManager] Invalid buffer size: \(size)") + return + } + + let wasRunning = isRunning + if wasRunning { + stop() + } + + currentBufferSize = size + + if wasRunning { + start() + } + + print("[AudioInputManager] Buffer size set to: \(size)") + } + + /// Starts audio capture + func start() { + guard !isRunning else { return } + + do { + // Create new audio engine + audioEngine = AVAudioEngine() + guard let engine = audioEngine else { return } + + inputNode = engine.inputNode + + guard let inputNode = inputNode else { + print("[AudioInputManager] No input node available") + return + } + + // Get the input format + let inputFormat = inputNode.outputFormat(forBus: 0) + + print("[AudioInputManager] Input format: \(inputFormat)") + + // Install tap on input node + let bufferSize = AVAudioFrameCount(currentBufferSize) + + inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in + self?.processingQueue.async { + self?.onAudioBuffer?(buffer) + } + } + + // Prepare and start the engine + engine.prepare() + try engine.start() + + DispatchQueue.main.async { + self.isRunning = true + } + + print("[AudioInputManager] Audio capture started") + + } catch { + print("[AudioInputManager] Failed to start audio capture: \(error)") + } + } + + /// Stops audio capture + func stop() { + guard isRunning else { return } + + inputNode?.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + inputNode = nil + + DispatchQueue.main.async { + self.isRunning = false + } + + print("[AudioInputManager] Audio capture stopped") + } + + // MARK: - Private Methods + + /// Gets device info for a specific device ID + private func getDeviceInfo(deviceID: AudioDeviceID) -> AudioDevice? { + // Check if device has input channels + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + + var dataSize: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &dataSize) + + guard status == noErr, dataSize > 0 else { return nil } + + let bufferListPointer = UnsafeMutablePointer.allocate(capacity: Int(dataSize)) + defer { bufferListPointer.deallocate() } + + status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &dataSize, bufferListPointer) + + guard status == noErr else { return nil } + + let bufferList = UnsafeMutableAudioBufferListPointer(bufferListPointer) + var inputChannelCount: UInt32 = 0 + for buffer in bufferList { + inputChannelCount += buffer.mNumberChannels + } + + guard inputChannelCount > 0 else { return nil } + + // Get device UID + var uid: CFString = "" as CFString + var uidSize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyDeviceUID + propertyAddress.mScope = kAudioObjectPropertyScopeGlobal + + status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid) + guard status == noErr else { return nil } + + // Get device name + var name: CFString = "" as CFString + var nameSize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString + + status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name) + guard status == noErr else { return nil } + + // Get manufacturer + var manufacturer: CFString = "" as CFString + var manufacturerSize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyDeviceManufacturerCFString + + AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &manufacturerSize, &manufacturer) + + return AudioDevice( + id: deviceID, + uid: uid as String, + name: name as String, + manufacturer: manufacturer as String, + isInput: true + ) + } + + /// Sets the system default input device + private func setSystemInputDevice(deviceID: AudioDeviceID) { + var deviceIDCopy = deviceID + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let status = AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + UInt32(MemoryLayout.size), + &deviceIDCopy + ) + + if status != noErr { + print("[AudioInputManager] Failed to set input device: \(status)") + } + } + + /// Sets up listener for device changes + private func setupDeviceChangeListener() { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + deviceListenerBlock = { [weak self] _, _ in + DispatchQueue.main.async { + self?.refreshDeviceList() + } + } + + if let block = deviceListenerBlock { + AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + DispatchQueue.main, + block + ) + } + } + + /// Removes device change listener + private func removeDeviceChangeListener() { + guard let block = deviceListenerBlock else { return } + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + DispatchQueue.main, + block + ) + } +} diff --git a/PsytranceVisualizer/Audio/DSPEngine.swift b/PsytranceVisualizer/Audio/DSPEngine.swift new file mode 100644 index 0000000..9f2b9d9 --- /dev/null +++ b/PsytranceVisualizer/Audio/DSPEngine.swift @@ -0,0 +1,468 @@ +// +// DSPEngine.swift +// PsytranceVisualizer +// +// Digital Signal Processing engine for audio analysis +// + +import Accelerate +import AVFoundation + +/// DSP Engine for real-time audio analysis +final class DSPEngine { + // MARK: - Configuration + + private let sampleRate: Float = 44100.0 + private var fftSize: Int + private let melBandCount: Int = 64 + private let subBassUpperFreq: Float = 100.0 + private let historySize: Int = 128 + + // MARK: - FFT Setup + + private var fftSetup: vDSP_DFT_Setup? + private var window: [Float] + private var realPart: [Float] + private var imagPart: [Float] + private var magnitudes: [Float] + + // MARK: - Mel Filterbank + + private var melFilterbank: [[Float]] + private var melOutput: [Float] + + // MARK: - Analysis State + + private var subBassHistory: [Float] + private var previousMagnitudes: [Float] + private var envelopeValue: Float = 0 + private var previousEnvelope: Float = 0 + private var pumpHistory: [Float] + private var lastPeakTime: Double = 0 + private var peakThreshold: Float = 0.3 + + // MARK: - Reactivity + + private var reactivity: Float = 0.5 + private var smoothingFactor: Float = 0.3 + + // MARK: - Initialization + + init(bufferSize: Int = 1024) { + self.fftSize = bufferSize + + // Initialize FFT arrays + self.window = [Float](repeating: 0, count: fftSize) + self.realPart = [Float](repeating: 0, count: fftSize) + self.imagPart = [Float](repeating: 0, count: fftSize) + self.magnitudes = [Float](repeating: 0, count: fftSize / 2) + self.previousMagnitudes = [Float](repeating: 0, count: fftSize / 2) + + // Initialize Mel arrays + self.melOutput = [Float](repeating: 0, count: melBandCount) + self.melFilterbank = [] + + // Initialize history arrays + self.subBassHistory = [Float](repeating: 0, count: historySize) + self.pumpHistory = [Float](repeating: 0, count: 64) + + // Create Hann window + vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM)) + + // Create FFT setup + fftSetup = vDSP_DFT_zop_CreateSetup( + nil, + vDSP_Length(fftSize), + .FORWARD + ) + + // Build Mel filterbank + buildMelFilterbank() + } + + deinit { + if let setup = fftSetup { + vDSP_DFT_DestroySetup(setup) + } + } + + // MARK: - Public Methods + + /// Sets reactivity value (0.0 - 1.0) + func setReactivity(_ value: Float) { + reactivity = max(0.0, min(1.0, value)) + // Adjust smoothing based on reactivity (higher reactivity = less smoothing) + smoothingFactor = 0.1 + (1.0 - reactivity) * 0.4 + } + + /// Reconfigures for new buffer size + func setBufferSize(_ size: Int) { + guard size != fftSize else { return } + + fftSize = size + + // Reinitialize arrays + window = [Float](repeating: 0, count: fftSize) + realPart = [Float](repeating: 0, count: fftSize) + imagPart = [Float](repeating: 0, count: fftSize) + magnitudes = [Float](repeating: 0, count: fftSize / 2) + previousMagnitudes = [Float](repeating: 0, count: fftSize / 2) + + // Recreate window + vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM)) + + // Recreate FFT setup + if let setup = fftSetup { + vDSP_DFT_DestroySetup(setup) + } + fftSetup = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(fftSize), .FORWARD) + + // Rebuild filterbank + buildMelFilterbank() + } + + /// Processes audio buffer and returns analysis data + func process(buffer: AVAudioPCMBuffer) -> AudioAnalysisData { + guard let channelData = buffer.floatChannelData else { + return .empty + } + + let frameCount = Int(buffer.frameLength) + let channelCount = Int(buffer.format.channelCount) + + // Extract stereo channels + var leftChannel = [Float](repeating: 0, count: frameCount) + var rightChannel = [Float](repeating: 0, count: frameCount) + + if channelCount >= 1 { + leftChannel = Array(UnsafeBufferPointer(start: channelData[0], count: frameCount)) + } + if channelCount >= 2 { + rightChannel = Array(UnsafeBufferPointer(start: channelData[1], count: frameCount)) + } else { + rightChannel = leftChannel + } + + // Mix to mono for analysis + var monoBuffer = [Float](repeating: 0, count: frameCount) + vDSP_vadd(leftChannel, 1, rightChannel, 1, &monoBuffer, 1, vDSP_Length(frameCount)) + var half: Float = 0.5 + vDSP_vsmul(monoBuffer, 1, &half, &monoBuffer, 1, vDSP_Length(frameCount)) + + // Calculate RMS + var rmsValue: Float = 0 + vDSP_rmsqv(monoBuffer, 1, &rmsValue, vDSP_Length(frameCount)) + + // Perform FFT + let fftMagnitudes = performFFT(monoBuffer) + + // Calculate Mel bands + let melBands = calculateMelBands(from: fftMagnitudes) + + // Extract sub-bass + let subBassEnergy = calculateSubBassEnergy(from: fftMagnitudes) + + // Update sub-bass history + subBassHistory.removeFirst() + subBassHistory.append(subBassEnergy) + + // Calculate sidechain envelope and pump detection + let (envelope, pumpAmount, isPumping) = detectSidechainPump(subBassEnergy: subBassEnergy) + + // Calculate HNR + let hnrRatio = calculateHNR(buffer: monoBuffer) + + // Detect peaks/transients + let (isPeak, peakIntensity) = detectPeak(rms: rmsValue) + + // Calculate spectral centroid + let spectralCentroid = calculateSpectralCentroid(magnitudes: fftMagnitudes) + + return AudioAnalysisData( + fftMagnitudes: fftMagnitudes, + melBands: melBands, + subBassEnergy: subBassEnergy, + subBassHistory: subBassHistory, + sidechainEnvelope: envelope, + sidechainPumpAmount: pumpAmount, + isPumping: isPumping, + hnrRatio: hnrRatio, + isPeak: isPeak, + peakIntensity: peakIntensity, + leftChannel: leftChannel, + rightChannel: rightChannel, + spectralCentroid: spectralCentroid, + rmsLevel: rmsValue + ) + } + + // MARK: - FFT + + private func performFFT(_ buffer: [Float]) -> [Float] { + guard let setup = fftSetup else { return magnitudes } + + let count = min(buffer.count, fftSize) + + // Apply window + var windowedBuffer = [Float](repeating: 0, count: fftSize) + for i in 0.. Float { + return 2595.0 * log10(1.0 + hz / 700.0) + } + + func melToHz(_ mel: Float) -> Float { + return 700.0 * (pow(10.0, mel / 2595.0) - 1.0) + } + + let melMin = hzToMel(20.0) + let melMax = hzToMel(nyquist) + + // Create mel points + var melPoints = [Float](repeating: 0, count: melBandCount + 2) + for i in 0.. [Float] { + var result = [Float](repeating: 0, count: melBandCount) + + for (i, filter) in melFilterbank.enumerated() { + var sum: Float = 0 + let count = min(filter.count, magnitudes.count) + for j in 0.. Float { + let binFrequency = sampleRate / Float(fftSize) + let subBassBinCount = Int(subBassUpperFreq / binFrequency) + + guard subBassBinCount > 0, magnitudes.count >= subBassBinCount else { return 0 } + + var sum: Float = 0 + for i in 0.. (envelope: Float, pumpAmount: Float, isPumping: Bool) { + // Envelope follower with fast attack, slow release + let attackTime: Float = 0.005 // 5ms attack + let releaseTime: Float = 0.15 // 150ms release + + let attackCoeff = exp(-1.0 / (sampleRate * attackTime)) + let releaseCoeff = exp(-1.0 / (sampleRate * releaseTime)) + + if subBassEnergy > envelopeValue { + envelopeValue = attackCoeff * envelopeValue + (1.0 - attackCoeff) * subBassEnergy + } else { + envelopeValue = releaseCoeff * envelopeValue + (1.0 - releaseCoeff) * subBassEnergy + } + + // Update pump history + pumpHistory.removeFirst() + pumpHistory.append(envelopeValue) + + // Analyze pump periodicity + var pumpAmount: Float = 0 + var isPumping = false + + // Look for characteristic pump pattern (rise and fall) + let derivative = envelopeValue - previousEnvelope + previousEnvelope = envelopeValue + + // Detect pump by finding periodic envelope variations + if pumpHistory.count >= 32 { + let recent = Array(pumpHistory.suffix(32)) + var variance: Float = 0 + let mean = recent.reduce(0, +) / Float(recent.count) + + for value in recent { + variance += (value - mean) * (value - mean) + } + variance /= Float(recent.count) + + // Higher variance = more pumping + pumpAmount = min(1.0, sqrt(variance) * 4.0) + isPumping = pumpAmount > 0.3 && abs(derivative) > 0.02 + } + + return (envelopeValue, pumpAmount, isPumping) + } + + // MARK: - HNR Calculation + + private func calculateHNR(buffer: [Float]) -> Float { + // Use autocorrelation to estimate harmonicity + let frameSize = min(buffer.count, 512) + var autocorr = [Float](repeating: 0, count: frameSize) + + // Compute autocorrelation + vDSP_conv(buffer, 1, buffer, 1, &autocorr, 1, vDSP_Length(frameSize), vDSP_Length(frameSize)) + + // Find the peak in autocorrelation (excluding lag 0) + let minLag = 20 // Minimum lag to avoid DC component + let maxLag = min(frameSize - 1, 400) // Maximum lag + + guard maxLag > minLag else { return 0.5 } + + var maxValue: Float = 0 + var maxIndex: vDSP_Length = 0 + + let searchRange = Array(autocorr[minLag...maxLag]) + vDSP_maxvi(searchRange, 1, &maxValue, &maxIndex, vDSP_Length(searchRange.count)) + + // Calculate HNR as ratio of peak to first value + let noiseFloor = autocorr.suffix(from: maxLag).reduce(0) { $0 + abs($1) } / Float(frameSize - maxLag) + + let harmonicPower = maxValue + let noisePower = max(noiseFloor, 0.0001) + + // Convert to 0-1 range + let hnr = harmonicPower / (harmonicPower + noisePower) + + return max(0.0, min(1.0, hnr)) + } + + // MARK: - Peak Detection + + private var previousRMS: Float = 0 + private var rmsHistory: [Float] = Array(repeating: 0, count: 16) + + private func detectPeak(rms: Float) -> (isPeak: Bool, intensity: Float) { + // Update history + rmsHistory.removeFirst() + rmsHistory.append(rms) + + // Calculate moving average + let average = rmsHistory.reduce(0, +) / Float(rmsHistory.count) + + // Detect sudden increase + let increase = rms - previousRMS + let threshold = average * (0.5 + reactivity * 0.5) + + previousRMS = rms + + let isPeak = increase > threshold && rms > average * 1.5 + let intensity = isPeak ? min(1.0, increase / max(average, 0.01) * 2.0) : 0 + + return (isPeak, intensity) + } + + // MARK: - Spectral Centroid + + private func calculateSpectralCentroid(magnitudes: [Float]) -> Float { + var weightedSum: Float = 0 + var sum: Float = 0 + + for (i, mag) in magnitudes.enumerated() { + weightedSum += Float(i) * mag + sum += mag + } + + guard sum > 0 else { return 0.5 } + + let centroid = weightedSum / sum + let normalized = centroid / Float(magnitudes.count) + + return max(0.0, min(1.0, normalized)) + } +} diff --git a/PsytranceVisualizer/Models/AppSettings.swift b/PsytranceVisualizer/Models/AppSettings.swift new file mode 100644 index 0000000..7a728be --- /dev/null +++ b/PsytranceVisualizer/Models/AppSettings.swift @@ -0,0 +1,90 @@ +// +// AppSettings.swift +// PsytranceVisualizer +// +// Persistent application settings +// + +import Foundation + +/// Application settings that are persisted between sessions +struct AppSettings: Codable { + /// Selected audio input device UID + var selectedAudioDeviceUID: String? + + /// Audio buffer size (512 or 1024 samples) + var bufferSize: Int + + /// Last used visualization mode (1-8) + var lastVisualizationMode: Int + + /// Reactivity slider value (0.0 - 1.0) + var reactivity: Float + + /// Whether app was in fullscreen mode + var isFullscreen: Bool + + /// Last window frame (for restoration) + var windowFrame: CodableRect? + + /// Volume/gain adjustment + var inputGain: Float + + /// Whether to show FPS counter + var showFPS: Bool + + /// Default settings + static var `default`: AppSettings { + AppSettings( + selectedAudioDeviceUID: nil, + bufferSize: 1024, + lastVisualizationMode: 1, + reactivity: 0.5, + isFullscreen: false, + windowFrame: nil, + inputGain: 1.0, + showFPS: false + ) + } + + /// Available buffer sizes + static let availableBufferSizes = [512, 1024] + + /// Validates and clamps settings to valid ranges + mutating func validate() { + // Clamp buffer size to valid options + if !AppSettings.availableBufferSizes.contains(bufferSize) { + bufferSize = 1024 + } + + // Clamp visualization mode + if lastVisualizationMode < 1 || lastVisualizationMode > 8 { + lastVisualizationMode = 1 + } + + // Clamp reactivity + reactivity = max(0.0, min(1.0, reactivity)) + + // Clamp input gain + inputGain = max(0.0, min(2.0, inputGain)) + } +} + +/// Codable wrapper for CGRect +struct CodableRect: Codable { + var x: Double + var y: Double + var width: Double + var height: Double + + init(from rect: CGRect) { + self.x = Double(rect.origin.x) + self.y = Double(rect.origin.y) + self.width = Double(rect.size.width) + self.height = Double(rect.size.height) + } + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} diff --git a/PsytranceVisualizer/Models/AudioAnalysisData.swift b/PsytranceVisualizer/Models/AudioAnalysisData.swift new file mode 100644 index 0000000..64040bd --- /dev/null +++ b/PsytranceVisualizer/Models/AudioAnalysisData.swift @@ -0,0 +1,111 @@ +// +// AudioAnalysisData.swift +// PsytranceVisualizer +// +// Audio analysis data structure containing all DSP results +// + +import Foundation + +/// Contains all audio analysis data computed by DSPEngine +struct AudioAnalysisData { + // MARK: - FFT Data + + /// Raw FFT magnitude spectrum + var fftMagnitudes: [Float] + + // MARK: - Mel Spectrogram + + /// 64 Mel frequency bands + var melBands: [Float] + + // MARK: - Sub-Bass Analysis + + /// RMS energy below 100Hz (0.0 - 1.0) + var subBassEnergy: Float + + /// History buffer for time-based visualization + var subBassHistory: [Float] + + // MARK: - Sidechain Detection + + /// Current envelope follower value (0.0 - 1.0) + var sidechainEnvelope: Float + + /// Detected pumping amount (0.0 - 1.0) + var sidechainPumpAmount: Float + + /// Whether pump is currently active + var isPumping: Bool + + // MARK: - Harmonic-to-Noise Ratio + + /// HNR ratio (0.0 = noise, 1.0 = pure harmonic) + var hnrRatio: Float + + // MARK: - Transient Detection + + /// Whether a transient peak was detected + var isPeak: Bool + + /// Intensity of the detected peak (0.0 - 1.0) + var peakIntensity: Float + + // MARK: - Stereo Channels + + /// Left channel samples + var leftChannel: [Float] + + /// Right channel samples + var rightChannel: [Float] + + // MARK: - Additional Analysis + + /// Spectral centroid (brightness) normalized 0.0 - 1.0 + var spectralCentroid: Float + + /// Overall RMS level + var rmsLevel: Float + + // MARK: - Initialization + + /// Creates an empty AudioAnalysisData with default values + static var empty: AudioAnalysisData { + AudioAnalysisData( + fftMagnitudes: [], + melBands: Array(repeating: 0, count: 64), + subBassEnergy: 0, + subBassHistory: [], + sidechainEnvelope: 0, + sidechainPumpAmount: 0, + isPumping: false, + hnrRatio: 0.5, + isPeak: false, + peakIntensity: 0, + leftChannel: [], + rightChannel: [], + spectralCentroid: 0.5, + rmsLevel: 0 + ) + } + + /// Creates AudioAnalysisData with specified FFT size + static func create(fftSize: Int) -> AudioAnalysisData { + AudioAnalysisData( + fftMagnitudes: Array(repeating: 0, count: fftSize / 2), + melBands: Array(repeating: 0, count: 64), + subBassEnergy: 0, + subBassHistory: Array(repeating: 0, count: 128), + sidechainEnvelope: 0, + sidechainPumpAmount: 0, + isPumping: false, + hnrRatio: 0.5, + isPeak: false, + peakIntensity: 0, + leftChannel: [], + rightChannel: [], + spectralCentroid: 0.5, + rmsLevel: 0 + ) + } +} diff --git a/PsytranceVisualizer/Models/VisualizationMode.swift b/PsytranceVisualizer/Models/VisualizationMode.swift new file mode 100644 index 0000000..00578c9 --- /dev/null +++ b/PsytranceVisualizer/Models/VisualizationMode.swift @@ -0,0 +1,109 @@ +// +// VisualizationMode.swift +// PsytranceVisualizer +// +// Enumeration of all available visualization modes +// + +import Foundation + +/// Available visualization modes, accessible via keyboard shortcuts 1-8 +enum VisualizationMode: Int, CaseIterable, Codable { + case fftClassic = 1 + case melSpectrogram = 2 + case subBass = 3 + case sidechainPump = 4 + case hnr = 5 + case mandelbrot = 6 + case tunnelWarp = 7 + case dmtGeometry = 8 + + /// Display name for UI + var displayName: String { + switch self { + case .fftClassic: + return "FFT Classic" + case .melSpectrogram: + return "Mel Spektrogramm" + case .subBass: + return "Sub-Bass (<100Hz)" + case .sidechainPump: + return "Sidechain Pump" + case .hnr: + return "Harmonic/Noise" + case .mandelbrot: + return "Mandelbrot" + case .tunnelWarp: + return "Tunnel Warp" + case .dmtGeometry: + return "DMT Geometry" + } + } + + /// Keyboard shortcut (1-8) + var shortcut: String { + return "\(self.rawValue)" + } + + /// Metal shader function name + var shaderFunctionName: String { + switch self { + case .fftClassic: + return "fftClassicFragment" + case .melSpectrogram: + return "melSpectrogramFragment" + case .subBass: + return "subBassFragment" + case .sidechainPump: + return "sidechainPumpFragment" + case .hnr: + return "hnrFragment" + case .mandelbrot: + return "mandelbrotFragment" + case .tunnelWarp: + return "tunnelWarpFragment" + case .dmtGeometry: + return "dmtGeometryFragment" + } + } + + /// Description of the visualization + var description: String { + switch self { + case .fftClassic: + return "Classic frequency spectrum bars with glow effects" + case .melSpectrogram: + return "64-band Mel spectrogram with scrolling waterfall display" + case .subBass: + return "Pulsating rings visualizing sub-bass energy below 100Hz" + case .sidechainPump: + return "Breathing zoom effect synchronized to sidechain pumping" + case .hnr: + return "Harmonic vs noise visualization with geometric shapes" + case .mandelbrot: + return "Audio-reactive Mandelbrot fractal with zoom and color cycling" + case .tunnelWarp: + return "Infinite tunnel effect with warp distortion" + case .dmtGeometry: + return "Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra" + } + } + + /// Creates mode from keyboard key code + static func fromKeyCode(_ keyCode: UInt16) -> VisualizationMode? { + // Key codes for 1-8 on US keyboard + let keyCodes: [UInt16: Int] = [ + 18: 1, // 1 + 19: 2, // 2 + 20: 3, // 3 + 21: 4, // 4 + 23: 5, // 5 + 22: 6, // 6 + 26: 7, // 7 + 28: 8 // 8 + ] + + guard let modeNumber = keyCodes[keyCode] else { return nil } + return VisualizationMode(rawValue: modeNumber) + } +} diff --git a/PsytranceVisualizer/Package.swift b/PsytranceVisualizer/Package.swift new file mode 100644 index 0000000..16d8537 --- /dev/null +++ b/PsytranceVisualizer/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "PsytranceVisualizer", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable( + name: "PsytranceVisualizer", + targets: ["PsytranceVisualizer"] + ) + ], + targets: [ + .executableTarget( + name: "PsytranceVisualizer", + path: ".", + exclude: [ + "Package.swift", + "README.md" + ], + sources: [ + "App", + "Audio", + "Models", + "Rendering", + "UI", + "Utilities" + ], + resources: [ + .process("Resources") + ], + swiftSettings: [ + .unsafeFlags(["-enable-bare-slash-regex"]) + ] + ) + ] +) diff --git a/PsytranceVisualizer/PsytranceVisualizer.xcodeproj/project.pbxproj b/PsytranceVisualizer/PsytranceVisualizer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..15fa3ad --- /dev/null +++ b/PsytranceVisualizer/PsytranceVisualizer.xcodeproj/project.pbxproj @@ -0,0 +1,465 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 1100000000000001 /* PsytranceVisualizerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000001 /* PsytranceVisualizerApp.swift */; }; + 1100000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000002 /* AppDelegate.swift */; }; + 1100000000000003 /* AudioInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000003 /* AudioInputManager.swift */; }; + 1100000000000004 /* DSPEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000004 /* DSPEngine.swift */; }; + 1100000000000005 /* AudioAnalysisData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000005 /* AudioAnalysisData.swift */; }; + 1100000000000006 /* VisualizationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000006 /* VisualizationMode.swift */; }; + 1100000000000007 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000007 /* AppSettings.swift */; }; + 1100000000000008 /* MetalRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000008 /* MetalRenderer.swift */; }; + 1100000000000009 /* Common.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000009 /* Common.metal */; }; + 1100000000000010 /* FFTClassicShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000010 /* FFTClassicShader.metal */; }; + 1100000000000011 /* MelSpectrogramShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000011 /* MelSpectrogramShader.metal */; }; + 1100000000000012 /* SubBassShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000012 /* SubBassShader.metal */; }; + 1100000000000013 /* SidechainPumpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000013 /* SidechainPumpShader.metal */; }; + 1100000000000014 /* HNRShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000014 /* HNRShader.metal */; }; + 1100000000000015 /* MandelbrotShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000015 /* MandelbrotShader.metal */; }; + 1100000000000016 /* TunnelWarpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000016 /* TunnelWarpShader.metal */; }; + 1100000000000017 /* DMTGeometryShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000017 /* DMTGeometryShader.metal */; }; + 1100000000000018 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000018 /* MainWindow.swift */; }; + 1100000000000019 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000019 /* ControlPanel.swift */; }; + 1100000000000020 /* VisualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000020 /* VisualizerView.swift */; }; + 1100000000000021 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000021 /* SettingsManager.swift */; }; + 1100000000000022 /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000022 /* ColorPalette.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2000000000000001 /* PsytranceVisualizer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PsytranceVisualizer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2100000000000001 /* PsytranceVisualizerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsytranceVisualizerApp.swift; sourceTree = ""; }; + 2100000000000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 2100000000000003 /* AudioInputManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioInputManager.swift; sourceTree = ""; }; + 2100000000000004 /* DSPEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSPEngine.swift; sourceTree = ""; }; + 2100000000000005 /* AudioAnalysisData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAnalysisData.swift; sourceTree = ""; }; + 2100000000000006 /* VisualizationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizationMode.swift; sourceTree = ""; }; + 2100000000000007 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 2100000000000008 /* MetalRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalRenderer.swift; sourceTree = ""; }; + 2100000000000009 /* Common.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Common.metal; sourceTree = ""; }; + 2100000000000010 /* FFTClassicShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = FFTClassicShader.metal; sourceTree = ""; }; + 2100000000000011 /* MelSpectrogramShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MelSpectrogramShader.metal; sourceTree = ""; }; + 2100000000000012 /* SubBassShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SubBassShader.metal; sourceTree = ""; }; + 2100000000000013 /* SidechainPumpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SidechainPumpShader.metal; sourceTree = ""; }; + 2100000000000014 /* HNRShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = HNRShader.metal; sourceTree = ""; }; + 2100000000000015 /* MandelbrotShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MandelbrotShader.metal; sourceTree = ""; }; + 2100000000000016 /* TunnelWarpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = TunnelWarpShader.metal; sourceTree = ""; }; + 2100000000000017 /* DMTGeometryShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = DMTGeometryShader.metal; sourceTree = ""; }; + 2100000000000018 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; + 2100000000000019 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = ""; }; + 2100000000000020 /* VisualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizerView.swift; sourceTree = ""; }; + 2100000000000021 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; + 2100000000000022 /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; + 2100000000000023 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2100000000000024 /* PsytranceVisualizer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PsytranceVisualizer.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4000000000000001 = { + isa = PBXGroup; + children = ( + 4000000000000002 /* App */, + 4000000000000003 /* Audio */, + 4000000000000004 /* Models */, + 4000000000000005 /* Rendering */, + 4000000000000007 /* UI */, + 4000000000000008 /* Utilities */, + 4000000000000009 /* Resources */, + 4000000000000010 /* Products */, + ); + sourceTree = ""; + }; + 4000000000000002 /* App */ = { + isa = PBXGroup; + children = ( + 2100000000000001 /* PsytranceVisualizerApp.swift */, + 2100000000000002 /* AppDelegate.swift */, + ); + path = App; + sourceTree = ""; + }; + 4000000000000003 /* Audio */ = { + isa = PBXGroup; + children = ( + 2100000000000003 /* AudioInputManager.swift */, + 2100000000000004 /* DSPEngine.swift */, + ); + path = Audio; + sourceTree = ""; + }; + 4000000000000004 /* Models */ = { + isa = PBXGroup; + children = ( + 2100000000000005 /* AudioAnalysisData.swift */, + 2100000000000006 /* VisualizationMode.swift */, + 2100000000000007 /* AppSettings.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4000000000000005 /* Rendering */ = { + isa = PBXGroup; + children = ( + 2100000000000008 /* MetalRenderer.swift */, + 4000000000000006 /* Shaders */, + ); + path = Rendering; + sourceTree = ""; + }; + 4000000000000006 /* Shaders */ = { + isa = PBXGroup; + children = ( + 2100000000000009 /* Common.metal */, + 2100000000000010 /* FFTClassicShader.metal */, + 2100000000000011 /* MelSpectrogramShader.metal */, + 2100000000000012 /* SubBassShader.metal */, + 2100000000000013 /* SidechainPumpShader.metal */, + 2100000000000014 /* HNRShader.metal */, + 2100000000000015 /* MandelbrotShader.metal */, + 2100000000000016 /* TunnelWarpShader.metal */, + 2100000000000017 /* DMTGeometryShader.metal */, + ); + path = Shaders; + sourceTree = ""; + }; + 4000000000000007 /* UI */ = { + isa = PBXGroup; + children = ( + 2100000000000018 /* MainWindow.swift */, + 2100000000000019 /* ControlPanel.swift */, + 2100000000000020 /* VisualizerView.swift */, + ); + path = UI; + sourceTree = ""; + }; + 4000000000000008 /* Utilities */ = { + isa = PBXGroup; + children = ( + 2100000000000021 /* SettingsManager.swift */, + 2100000000000022 /* ColorPalette.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 4000000000000009 /* Resources */ = { + isa = PBXGroup; + children = ( + 2100000000000023 /* Info.plist */, + 2100000000000024 /* PsytranceVisualizer.entitlements */, + ); + path = Resources; + sourceTree = ""; + }; + 4000000000000010 /* Products */ = { + isa = PBXGroup; + children = ( + 2000000000000001 /* PsytranceVisualizer.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5000000000000001 /* PsytranceVisualizer */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */; + buildPhases = ( + 5000000000000002 /* Sources */, + 3000000000000001 /* Frameworks */, + 5000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PsytranceVisualizer; + productName = PsytranceVisualizer; + productReference = 2000000000000001 /* PsytranceVisualizer.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 5000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4000000000000001; + productRefGroup = 4000000000000010 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5000000000000001 /* PsytranceVisualizer */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1100000000000001 /* PsytranceVisualizerApp.swift in Sources */, + 1100000000000002 /* AppDelegate.swift in Sources */, + 1100000000000003 /* AudioInputManager.swift in Sources */, + 1100000000000004 /* DSPEngine.swift in Sources */, + 1100000000000005 /* AudioAnalysisData.swift in Sources */, + 1100000000000006 /* VisualizationMode.swift in Sources */, + 1100000000000007 /* AppSettings.swift in Sources */, + 1100000000000008 /* MetalRenderer.swift in Sources */, + 1100000000000009 /* Common.metal in Sources */, + 1100000000000010 /* FFTClassicShader.metal in Sources */, + 1100000000000011 /* MelSpectrogramShader.metal in Sources */, + 1100000000000012 /* SubBassShader.metal in Sources */, + 1100000000000013 /* SidechainPumpShader.metal in Sources */, + 1100000000000014 /* HNRShader.metal in Sources */, + 1100000000000015 /* MandelbrotShader.metal in Sources */, + 1100000000000016 /* TunnelWarpShader.metal in Sources */, + 1100000000000017 /* DMTGeometryShader.metal in Sources */, + 1100000000000018 /* MainWindow.swift in Sources */, + 1100000000000019 /* ControlPanel.swift in Sources */, + 1100000000000020 /* VisualizerView.swift in Sources */, + 1100000000000021 /* SettingsManager.swift in Sources */, + 1100000000000022 /* ColorPalette.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 6100000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 6100000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 6100000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Resources/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time."; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 6100000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Resources/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time."; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6100000000000001 /* Debug */, + 6100000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6100000000000003 /* Debug */, + 6100000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0000000000000001 /* Project object */; +} diff --git a/PsytranceVisualizer/Rendering/MetalRenderer.swift b/PsytranceVisualizer/Rendering/MetalRenderer.swift new file mode 100644 index 0000000..6715819 --- /dev/null +++ b/PsytranceVisualizer/Rendering/MetalRenderer.swift @@ -0,0 +1,279 @@ +// +// MetalRenderer.swift +// PsytranceVisualizer +// +// Metal-based renderer for all visualization modes +// + +import MetalKit +import simd + +/// Uniform data passed to all shaders +struct ShaderUniforms { + var time: Float + var resolution: SIMD2 + var reactivity: Float + + // Audio analysis data + var subBassEnergy: Float + var sidechainPump: Float + var sidechainEnvelope: Float + var hnrRatio: Float + var isPeak: Float + var peakIntensity: Float + var spectralCentroid: Float + var rmsLevel: Float + + // Visualization mode (1-8) + var mode: Int32 + + // Padding for Metal alignment + var padding: SIMD2 = .zero +} + +/// Metal renderer managing all visualization shaders +final class MetalRenderer: NSObject, ObservableObject { + // MARK: - Properties + + private let device: MTLDevice + private let commandQueue: MTLCommandQueue + private var pipelineStates: [VisualizationMode: MTLRenderPipelineState] = [:] + private var currentPipelineState: MTLRenderPipelineState? + + @Published private(set) var currentMode: VisualizationMode = .fftClassic + + // MARK: - Buffers + + private var uniformBuffer: MTLBuffer? + private var fftBuffer: MTLBuffer? + private var melBuffer: MTLBuffer? + private var subBassHistoryBuffer: MTLBuffer? + + // MARK: - State + + private var startTime: CFAbsoluteTime + private var uniforms = ShaderUniforms( + time: 0, + resolution: SIMD2(1920, 1080), + reactivity: 0.5, + subBassEnergy: 0, + sidechainPump: 0, + sidechainEnvelope: 0, + hnrRatio: 0.5, + isPeak: 0, + peakIntensity: 0, + spectralCentroid: 0.5, + rmsLevel: 0, + mode: 1 + ) + + private var audioData: AudioAnalysisData = .empty + + // MARK: - Constants + + private let maxFFTSize = 1024 + private let melBandCount = 64 + private let historySize = 128 + + // MARK: - Initialization + + init?(device: MTLDevice) { + guard let queue = device.makeCommandQueue() else { + print("[MetalRenderer] Failed to create command queue") + return nil + } + + self.device = device + self.commandQueue = queue + self.startTime = CFAbsoluteTimeGetCurrent() + + super.init() + + createBuffers() + loadShaders() + } + + // MARK: - Public Methods + + /// Sets the current visualization mode + func setVisualizationMode(_ mode: VisualizationMode) { + currentMode = mode + currentPipelineState = pipelineStates[mode] + uniforms.mode = Int32(mode.rawValue) + print("[MetalRenderer] Mode changed to: \(mode.displayName)") + } + + /// Updates audio analysis data + func updateAudioData(_ data: AudioAnalysisData) { + audioData = data + + // Update uniforms + uniforms.subBassEnergy = data.subBassEnergy + uniforms.sidechainPump = data.sidechainPumpAmount + uniforms.sidechainEnvelope = data.sidechainEnvelope + uniforms.hnrRatio = data.hnrRatio + uniforms.isPeak = data.isPeak ? 1.0 : 0.0 + uniforms.peakIntensity = data.peakIntensity + uniforms.spectralCentroid = data.spectralCentroid + uniforms.rmsLevel = data.rmsLevel + + // Update FFT buffer + updateFFTBuffer(data.fftMagnitudes) + + // Update Mel buffer + updateMelBuffer(data.melBands) + + // Update sub-bass history buffer + updateSubBassHistoryBuffer(data.subBassHistory) + } + + /// Sets reactivity value + func setReactivity(_ value: Float) { + uniforms.reactivity = max(0.0, min(1.0, value)) + } + + // MARK: - Private Methods + + private func createBuffers() { + // Uniform buffer + uniformBuffer = device.makeBuffer( + length: MemoryLayout.stride, + options: .storageModeShared + ) + + // FFT magnitude buffer + fftBuffer = device.makeBuffer( + length: maxFFTSize * MemoryLayout.stride, + options: .storageModeShared + ) + + // Mel bands buffer + melBuffer = device.makeBuffer( + length: melBandCount * MemoryLayout.stride, + options: .storageModeShared + ) + + // Sub-bass history buffer + subBassHistoryBuffer = device.makeBuffer( + length: historySize * MemoryLayout.stride, + options: .storageModeShared + ) + } + + private func updateFFTBuffer(_ magnitudes: [Float]) { + guard let buffer = fftBuffer else { return } + let count = min(magnitudes.count, maxFFTSize) + memcpy(buffer.contents(), magnitudes, count * MemoryLayout.stride) + } + + private func updateMelBuffer(_ bands: [Float]) { + guard let buffer = melBuffer else { return } + let count = min(bands.count, melBandCount) + memcpy(buffer.contents(), bands, count * MemoryLayout.stride) + } + + private func updateSubBassHistoryBuffer(_ history: [Float]) { + guard let buffer = subBassHistoryBuffer else { return } + let count = min(history.count, historySize) + memcpy(buffer.contents(), history, count * MemoryLayout.stride) + } + + private func loadShaders() { + guard let library = device.makeDefaultLibrary() else { + print("[MetalRenderer] Failed to load shader library") + return + } + + // Load vertex shader (shared) + guard let vertexFunction = library.makeFunction(name: "vertexShader") else { + print("[MetalRenderer] Failed to load vertex shader") + return + } + + // Load all fragment shaders + for mode in VisualizationMode.allCases { + guard let fragmentFunction = library.makeFunction(name: mode.shaderFunctionName) else { + print("[MetalRenderer] Failed to load shader: \(mode.shaderFunctionName)") + continue + } + + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = vertexFunction + descriptor.fragmentFunction = fragmentFunction + descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + + // Enable blending for glow effects + descriptor.colorAttachments[0].isBlendingEnabled = true + descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one + descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + + do { + let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor) + pipelineStates[mode] = pipelineState + print("[MetalRenderer] Loaded shader: \(mode.displayName)") + } catch { + print("[MetalRenderer] Failed to create pipeline state for \(mode.displayName): \(error)") + } + } + + // Set initial pipeline state + currentPipelineState = pipelineStates[.fftClassic] + } +} + +// MARK: - MTKViewDelegate + +extension MetalRenderer: MTKViewDelegate { + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + uniforms.resolution = SIMD2(Float(size.width), Float(size.height)) + } + + func draw(in view: MTKView) { + guard let pipelineState = currentPipelineState, + let drawable = view.currentDrawable, + let renderPassDescriptor = view.currentRenderPassDescriptor else { + return + } + + // Update time + uniforms.time = Float(CFAbsoluteTimeGetCurrent() - startTime) + + // Update uniform buffer + if let buffer = uniformBuffer { + memcpy(buffer.contents(), &uniforms, MemoryLayout.stride) + } + + // Create command buffer + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { + return + } + + // Set pipeline state + renderEncoder.setRenderPipelineState(pipelineState) + + // Set buffers + if let buffer = uniformBuffer { + renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 0) + } + if let buffer = fftBuffer { + renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 1) + } + if let buffer = melBuffer { + renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 2) + } + if let buffer = subBassHistoryBuffer { + renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 3) + } + + // Draw fullscreen quad + renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + + renderEncoder.endEncoding() + + commandBuffer.present(drawable) + commandBuffer.commit() + } +} diff --git a/PsytranceVisualizer/Rendering/Shaders/Common.metal b/PsytranceVisualizer/Rendering/Shaders/Common.metal new file mode 100644 index 0000000..47be941 --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/Common.metal @@ -0,0 +1,241 @@ +// +// Common.metal +// PsytranceVisualizer +// +// Shared shader functions, types, and psytrance color palette +// + +#include +using namespace metal; + +// MARK: - Uniforms Structure + +struct ShaderUniforms { + float time; + float2 resolution; + float reactivity; + + float subBassEnergy; + float sidechainPump; + float sidechainEnvelope; + float hnrRatio; + float isPeak; + float peakIntensity; + float spectralCentroid; + float rmsLevel; + + int mode; + float2 padding; +}; + +// MARK: - Vertex Data + +struct VertexOut { + float4 position [[position]]; + float2 uv; +}; + +// MARK: - Psytrance Color Palette + +constant float3 neonMagenta = float3(1.0, 0.0, 1.0); +constant float3 neonCyan = float3(0.0, 1.0, 1.0); +constant float3 neonGreen = float3(0.224, 1.0, 0.078); +constant float3 uvViolet = float3(0.482, 0.0, 1.0); +constant float3 hotPink = float3(1.0, 0.2, 0.6); +constant float3 electricBlue = float3(0.0, 0.5, 1.0); +constant float3 deepPurple = float3(0.1, 0.0, 0.15); + +// MARK: - Palette Functions + +inline float3 getPaletteColor(int index) { + switch (index % 6) { + case 0: return neonMagenta; + case 1: return neonCyan; + case 2: return neonGreen; + case 3: return uvViolet; + case 4: return hotPink; + default: return electricBlue; + } +} + +inline float3 rainbowPalette(float t) { + float3 a = float3(0.5, 0.5, 0.5); + float3 b = float3(0.5, 0.5, 0.5); + float3 c = float3(1.0, 1.0, 1.0); + float3 d = float3(0.0, 0.33, 0.67); + return a + b * cos(6.28318 * (c * t + d)); +} + +inline float3 psytrancePalette(float t, float time) { + // Cycle through psytrance colors + float phase = fract(t + time * 0.1); + + if (phase < 0.2) { + return mix(uvViolet, neonMagenta, phase * 5.0); + } else if (phase < 0.4) { + return mix(neonMagenta, hotPink, (phase - 0.2) * 5.0); + } else if (phase < 0.6) { + return mix(hotPink, neonCyan, (phase - 0.4) * 5.0); + } else if (phase < 0.8) { + return mix(neonCyan, neonGreen, (phase - 0.6) * 5.0); + } else { + return mix(neonGreen, uvViolet, (phase - 0.8) * 5.0); + } +} + +// MARK: - Heatmap for Spectrogram + +inline float3 heatmap(float t) { + // Low energy: dark purple + // High energy: white through neon colors + if (t < 0.2) { + return mix(float3(0.05, 0.0, 0.1), uvViolet, t * 5.0); + } else if (t < 0.4) { + return mix(uvViolet, neonMagenta, (t - 0.2) * 5.0); + } else if (t < 0.6) { + return mix(neonMagenta, hotPink, (t - 0.4) * 5.0); + } else if (t < 0.8) { + return mix(hotPink, neonCyan, (t - 0.6) * 5.0); + } else { + return mix(neonCyan, float3(1.0), (t - 0.8) * 5.0); + } +} + +// MARK: - Noise Functions + +// Simplex-like noise +inline float hash(float2 p) { + float3 p3 = fract(float3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +inline float noise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + float a = hash(i); + float b = hash(i + float2(1.0, 0.0)); + float c = hash(i + float2(0.0, 1.0)); + float d = hash(i + float2(1.0, 1.0)); + + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +inline float fbm(float2 p, int octaves) { + float value = 0.0; + float amplitude = 0.5; + float frequency = 1.0; + + for (int i = 0; i < octaves; i++) { + value += amplitude * noise(p * frequency); + frequency *= 2.0; + amplitude *= 0.5; + } + + return value; +} + +// 3D noise for volumetric effects +inline float noise3D(float3 p) { + float3 i = floor(p); + float3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + float2 uv = i.xy + float2(37.0, 17.0) * i.z; + float a = hash(uv); + float b = hash(uv + float2(1.0, 0.0)); + float c = hash(uv + float2(0.0, 1.0)); + float d = hash(uv + float2(1.0, 1.0)); + + float2 uv2 = uv + float2(37.0, 17.0); + float e = hash(uv2); + float ff = hash(uv2 + float2(1.0, 0.0)); + float g = hash(uv2 + float2(0.0, 1.0)); + float h = hash(uv2 + float2(1.0, 1.0)); + + float x1 = mix(mix(a, b, f.x), mix(c, d, f.x), f.y); + float x2 = mix(mix(e, ff, f.x), mix(g, h, f.x), f.y); + + return mix(x1, x2, f.z); +} + +// MARK: - Utility Functions + +inline float2 rotate(float2 p, float angle) { + float c = cos(angle); + float s = sin(angle); + return float2(p.x * c - p.y * s, p.x * s + p.y * c); +} + +inline float map(float value, float inMin, float inMax, float outMin, float outMax) { + return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin); +} + +inline float smoothstepEdge(float edge0, float edge1, float x) { + float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +// MARK: - Glow Effect + +inline float3 addGlow(float3 color, float intensity, float3 glowColor) { + return color + glowColor * intensity * intensity; +} + +// MARK: - SDF Functions for Geometry + +inline float sdCircle(float2 p, float r) { + return length(p) - r; +} + +inline float sdBox(float2 p, float2 b) { + float2 d = abs(p) - b; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); +} + +inline float sdHexagon(float2 p, float r) { + const float3 k = float3(-0.866025404, 0.5, 0.577350269); + p = abs(p); + p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy; + p -= float2(clamp(p.x, -k.z * r, k.z * r), r); + return length(p) * sign(p.y); +} + +inline float sdStar(float2 p, float r, int n, float m) { + float an = 3.141593 / float(n); + float en = 3.141593 / m; + float2 acs = float2(cos(an), sin(an)); + float2 ecs = float2(cos(en), sin(en)); + + float bn = fmod(atan2(p.x, p.y), 2.0 * an) - an; + p = length(p) * float2(cos(bn), abs(sin(bn))); + p -= r * acs; + p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y); + return length(p) * sign(p.x); +} + +// MARK: - Vertex Shader (Fullscreen Quad) + +vertex VertexOut vertexShader(uint vertexID [[vertex_id]]) { + // Generate fullscreen quad + float2 positions[4] = { + float2(-1.0, -1.0), + float2( 1.0, -1.0), + float2(-1.0, 1.0), + float2( 1.0, 1.0) + }; + + float2 uvs[4] = { + float2(0.0, 1.0), + float2(1.0, 1.0), + float2(0.0, 0.0), + float2(1.0, 0.0) + }; + + VertexOut out; + out.position = float4(positions[vertexID], 0.0, 1.0); + out.uv = uvs[vertexID]; + return out; +} diff --git a/PsytranceVisualizer/Rendering/Shaders/DMTGeometryShader.metal b/PsytranceVisualizer/Rendering/Shaders/DMTGeometryShader.metal new file mode 100644 index 0000000..af96e6d --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/DMTGeometryShader.metal @@ -0,0 +1,290 @@ +// +// DMTGeometryShader.metal +// PsytranceVisualizer +// +// Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra, Hexagonal +// + +#include +using namespace metal; + +#include "Common.metal" + +// === SACRED GEOMETRY PRIMITIVES === + +// Flower of Life - overlapping circles +float flowerOfLife(float2 p, float scale, float time) { + p *= scale; + + float result = 0.0; + float circleRadius = 0.5; + + // Center circle + result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, length(p))); + + // 6 circles around center + for (int i = 0; i < 6; i++) { + float angle = float(i) * 3.14159 / 3.0 + time * 0.1; + float2 offset = float2(cos(angle), sin(angle)) * circleRadius; + float d = length(p - offset); + result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, d)); + } + + // Second ring of 12 circles + for (int i = 0; i < 12; i++) { + float angle = float(i) * 3.14159 / 6.0 + time * 0.05; + float2 offset = float2(cos(angle), sin(angle)) * circleRadius * 2.0; + float d = length(p - offset); + result = max(result, 0.5 * (1.0 - smoothstep(circleRadius - 0.02, circleRadius, d))); + } + + return result; +} + +// Metatron's Cube - 13 circles with connecting lines +float metatronsCube(float2 p, float scale, float time) { + p *= scale; + + float result = 0.0; + float nodeRadius = 0.08; + float lineWidth = 0.01; + + // Define the 13 points of Metatron's Cube + float2 points[13]; + points[0] = float2(0.0, 0.0); // Center + + // Inner hexagon + for (int i = 0; i < 6; i++) { + float angle = float(i) * 3.14159 / 3.0 + time * 0.1; + points[i + 1] = float2(cos(angle), sin(angle)) * 0.5; + } + + // Outer hexagon (rotated) + for (int i = 0; i < 6; i++) { + float angle = float(i) * 3.14159 / 3.0 + 3.14159 / 6.0 + time * 0.1; + points[i + 7] = float2(cos(angle), sin(angle)) * 0.866; + } + + // Draw nodes + for (int i = 0; i < 13; i++) { + float d = length(p - points[i]); + float node = 1.0 - smoothstep(nodeRadius - 0.01, nodeRadius, d); + result = max(result, node); + } + + // Draw connecting lines + for (int i = 0; i < 13; i++) { + for (int j = i + 1; j < 13; j++) { + float2 a = points[i]; + float2 b = points[j]; + float2 pa = p - a; + float2 ba = b - a; + float t = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + float d = length(pa - ba * t); + float line = 1.0 - smoothstep(lineWidth, lineWidth + 0.005, d); + result = max(result, line * 0.5); + } + } + + return result; +} + +// Sri Yantra - 9 interlocking triangles +float sriYantra(float2 p, float scale, float time) { + p *= scale; + + float result = 0.0; + float lineWidth = 0.015; + + // Rotating factor + float rot = time * 0.05; + + // Draw 4 upward triangles + for (int i = 0; i < 4; i++) { + float size = 0.3 + float(i) * 0.15; + float yOffset = -0.1 + float(i) * 0.05; + + float2 tp = p - float2(0.0, yOffset); + tp = rotate(tp, rot); + + // Triangle SDF + float2 a = float2(0.0, size); + float2 b = float2(-size * 0.866, -size * 0.5); + float2 c = float2(size * 0.866, -size * 0.5); + + float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x))); + float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x))); + float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x))); + + float triangleDist = max(max(d1, d2), d3); + float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist)); + result = max(result, edge * (1.0 - float(i) * 0.15)); + } + + // Draw 5 downward triangles + for (int i = 0; i < 5; i++) { + float size = 0.25 + float(i) * 0.12; + float yOffset = 0.1 - float(i) * 0.04; + + float2 tp = p - float2(0.0, yOffset); + tp = rotate(tp, -rot); + + float2 a = float2(0.0, -size); + float2 b = float2(-size * 0.866, size * 0.5); + float2 c = float2(size * 0.866, size * 0.5); + + float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x))); + float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x))); + float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x))); + + float triangleDist = max(max(d1, d2), d3); + float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist)); + result = max(result, edge * (1.0 - float(i) * 0.12)); + } + + // Central bindu (point) + float bindu = 1.0 - smoothstep(0.03, 0.04, length(p)); + result = max(result, bindu); + + return result; +} + +// Hexagonal grid pattern +float hexagonalPattern(float2 p, float scale, float time) { + p *= scale; + + // Hexagonal grid transformation + float2 s = float2(1.0, 1.732); + float2 h = s * 0.5; + + float2 a = fmod(p, s) - h; + float2 b = fmod(p + h, s) - h; + + float2 gv = dot(a, a) < dot(b, b) ? a : b; + + float hexDist = max(abs(gv.x), dot(abs(gv), normalize(float2(1.0, 1.732)))); + + float edge = 1.0 - smoothstep(0.4, 0.42, hexDist); + float fill = smoothstep(0.38, 0.4, hexDist); + + // Animate individual hexagons + float2 cellId = floor(p / s); + float cellPhase = hash(cellId + floor(time * 0.5)) * 2.0 * 3.14159; + float pulse = 0.5 + 0.5 * sin(time * 3.0 + cellPhase); + + return edge + fill * pulse * 0.3; +} + +// === MAIN FRAGMENT SHADER === + +fragment float4 dmtGeometryFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + + float subBass = uniforms.subBassEnergy; + float hnr = uniforms.hnrRatio; + float peak = uniforms.isPeak; + float peakIntensity = uniforms.peakIntensity; + + // Aspect ratio correction + float aspectRatio = resolution.x / resolution.y; + float2 p = (uv - 0.5) * 2.0; + p.x *= aspectRatio; + + // Scale pulsing with sub-bass + float scale = 2.0 + subBass * 0.5 * (0.5 + reactivity * 0.5); + p *= scale; + + // Rotation + float rotation = time * 0.1; + p = rotate(p, rotation); + + // Determine which geometry to show + // Changes on peaks or every few seconds + float cycleTime = 8.0; // Seconds per geometry + float cyclePhase = fmod(time, cycleTime * 4.0) / cycleTime; + int geometryIndex = int(cyclePhase); + + // Force change on strong peaks + if (peak > 0.5 && peakIntensity > 0.7) { + geometryIndex = int(fmod(float(geometryIndex) + 1.0, 4.0)); + } + + // Calculate all geometries (for blending) + float flower = flowerOfLife(p, 1.0, time); + float metatron = metatronsCube(p, 1.5, time); + float yantra = sriYantra(p, 1.2, time); + float hexGrid = hexagonalPattern(p, 3.0, time); + + // Select primary and secondary for blending + float primary = 0.0; + float secondary = 0.0; + float blendPhase = fract(cyclePhase); + + switch (geometryIndex) { + case 0: + primary = flower; + secondary = metatron; + break; + case 1: + primary = metatron; + secondary = yantra; + break; + case 2: + primary = yantra; + secondary = hexGrid; + break; + default: + primary = hexGrid; + secondary = flower; + break; + } + + // Smooth transition + float transitionWindow = 0.2; // 20% of cycle for transition + float blend = smoothstep(1.0 - transitionWindow, 1.0, blendPhase); + float geometry = mix(primary, secondary, blend); + + // Complexity based on HNR (more harmonic = more detail) + geometry *= 0.7 + hnr * 0.3; + + // Color based on geometry and audio + float colorPhase = time * 0.1 + geometry * 0.5; + float3 geometryColor = psytrancePalette(colorPhase, time); + + // Glow intensity from peak + float glowIntensity = 0.5 + peakIntensity * 0.5; + float3 glowColor = mix(neonMagenta, neonCyan, 0.5 + 0.5 * sin(time)); + + // Compose final color + float3 finalColor = geometryColor * geometry; + + // Add glow + finalColor = addGlow(finalColor, geometry * glowIntensity, glowColor); + + // Background - subtle pulsing gradient + float dist = length(uv - 0.5); + float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist); + bgColor *= 0.8 + 0.2 * subBass; + + finalColor = mix(bgColor, finalColor, clamp(geometry * 1.5, 0.0, 1.0)); + + // Peak flash + if (peak > 0.5) { + finalColor += float3(1.0) * peakIntensity * 0.2; + } + + // Outer glow + float outerGlow = exp(-dist * 3.0); + finalColor += neonMagenta * outerGlow * 0.1 * subBass; + + return float4(finalColor, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/FFTClassicShader.metal b/PsytranceVisualizer/Rendering/Shaders/FFTClassicShader.metal new file mode 100644 index 0000000..1a49deb --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/FFTClassicShader.metal @@ -0,0 +1,117 @@ +// +// FFTClassicShader.metal +// PsytranceVisualizer +// +// Classic FFT bar visualization with glow effects +// + +#include +using namespace metal; + +// Include common definitions +#include "Common.metal" + +fragment float4 fftClassicFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + + // Number of bars to display + const int numBars = 64; + const float barWidth = 1.0 / float(numBars); + const float barGap = barWidth * 0.2; + const float actualBarWidth = barWidth - barGap; + + // Determine which bar this pixel belongs to + int barIndex = int(uv.x * float(numBars)); + barIndex = clamp(barIndex, 0, numBars - 1); + + // Get FFT magnitude for this bar (with some averaging for smoothness) + float magnitude = fftData[barIndex]; + + // Apply reactivity scaling + magnitude = magnitude * (0.5 + reactivity * 1.5); + magnitude = clamp(magnitude, 0.0, 1.0); + + // Calculate bar position within its cell + float barCellX = fract(uv.x * float(numBars)); + float barCenterX = 0.5; + + // Distance from bar center (for width calculation) + float distFromCenter = abs(barCellX - barCenterX); + float halfWidth = actualBarWidth * 0.5 / barWidth; + + // Check if we're inside the bar horizontally + bool insideBarX = distFromCenter < halfWidth; + + // Bar height from bottom + float barHeight = magnitude; + + // Add some bounce on peaks + if (uniforms.isPeak > 0.5) { + barHeight += uniforms.peakIntensity * 0.1 * sin(time * 20.0 + float(barIndex) * 0.3); + } + + // Check if we're inside the bar vertically (from bottom) + float yFromBottom = 1.0 - uv.y; + bool insideBarY = yFromBottom < barHeight; + + // Color based on frequency and magnitude + float colorPhase = float(barIndex) / float(numBars) + time * 0.05; + float3 barColor = psytrancePalette(colorPhase, time); + + // Intensity gradient from bottom to top + float intensityGradient = yFromBottom / max(barHeight, 0.01); + intensityGradient = clamp(intensityGradient, 0.0, 1.0); + + // Make top of bars brighter + barColor = mix(barColor * 0.6, barColor * 1.5, intensityGradient); + + // Calculate glow + float glowRadius = 0.05 * (1.0 + magnitude); + float distToBar = 0.0; + + if (!insideBarX) { + distToBar = (distFromCenter - halfWidth) * barWidth; + } + if (!insideBarY && yFromBottom >= barHeight) { + float vertDist = yFromBottom - barHeight; + distToBar = max(distToBar, vertDist); + } + + float glow = exp(-distToBar * distToBar / (glowRadius * glowRadius * 2.0)); + glow *= magnitude; + + // Final color + float3 finalColor = float3(0.0); + + if (insideBarX && insideBarY) { + // Inside the bar + finalColor = barColor; + + // Add peak cap (bright line at top) + float capThickness = 0.01; + if (abs(yFromBottom - barHeight) < capThickness) { + finalColor = float3(1.0); // White cap + } + } else { + // Add glow outside bars + finalColor = barColor * glow * 0.5; + } + + // Add subtle background pulse with sub-bass + float bgPulse = uniforms.subBassEnergy * 0.05; + finalColor += deepPurple * bgPulse; + + // Add overall glow at peaks + if (uniforms.isPeak > 0.5) { + finalColor += neonMagenta * uniforms.peakIntensity * 0.1; + } + + return float4(finalColor, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/HNRShader.metal b/PsytranceVisualizer/Rendering/Shaders/HNRShader.metal new file mode 100644 index 0000000..df51f40 --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/HNRShader.metal @@ -0,0 +1,142 @@ +// +// HNRShader.metal +// PsytranceVisualizer +// +// Harmonic-to-Noise ratio visualization with geometric shapes vs chaos +// + +#include +using namespace metal; + +#include "Common.metal" + +fragment float4 hnrFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + float hnr = uniforms.hnrRatio; + float subBass = uniforms.subBassEnergy; + + // Center coordinates + float2 center = float2(0.5, 0.5); + float aspectRatio = resolution.x / resolution.y; + + float2 p = uv - center; + p.x *= aspectRatio; + + float dist = length(p); + float angle = atan2(p.y, p.x); + + // === HARMONIC SIDE (High HNR = Clear geometric shapes) === + + // Rotating hexagon + float2 rotP = rotate(p, time * 0.5); + float hexDist = sdHexagon(rotP, 0.2 + subBass * 0.1); + float hexEdge = 1.0 - smoothstep(0.0, 0.02, abs(hexDist)); + + // Inner rotating triangle (star) + float2 rotP2 = rotate(p, -time * 0.3); + float starDist = sdStar(rotP2, 0.12 + subBass * 0.05, 3, 2.5); + float starEdge = 1.0 - smoothstep(0.0, 0.015, abs(starDist)); + + // Concentric circles + float circles = 0.0; + for (int i = 0; i < 4; i++) { + float radius = 0.1 + float(i) * 0.08 + sin(time + float(i)) * 0.02; + float circleDist = abs(dist - radius); + float circle = 1.0 - smoothstep(0.0, 0.008, circleDist); + circles += circle; + } + + // Combine harmonic shapes + float harmonicShapes = hexEdge + starEdge * 0.8 + circles * 0.5; + harmonicShapes = clamp(harmonicShapes, 0.0, 1.0); + + // Harmonic color - clean neon + float3 harmonicColor = mix(neonCyan, neonMagenta, 0.5 + 0.5 * sin(angle * 2.0 + time)); + + // === NOISE SIDE (Low HNR = Chaotic particles) === + + // Noise-based particles + float noiseField = 0.0; + for (int i = 0; i < 5; i++) { + float2 noiseP = p * (3.0 + float(i) * 2.0); + noiseP += time * float(i + 1) * 0.1; + float n = noise(noiseP); + n = pow(n, 2.0); + noiseField += n * (1.0 / float(i + 1)); + } + noiseField = clamp(noiseField, 0.0, 1.0); + + // Turbulent swirls + float2 turbP = p * 4.0; + float turbulence = fbm(turbP + time * 0.5, 4); + + // Chaotic speckles + float speckles = 0.0; + for (int i = 0; i < 30; i++) { + float2 specklePos = float2( + hash(float2(float(i) * 0.1, time * 0.01)) - 0.5, + hash(float2(float(i) * 0.2, time * 0.01 + 0.5)) - 0.5 + ); + specklePos *= 0.8; + specklePos.x *= aspectRatio; + + float speckleDist = length(p - specklePos); + float speckle = exp(-speckleDist * speckleDist * 500.0); + speckle *= hash(float2(float(i), floor(time * 2.0))); + speckles += speckle; + } + + float noiseVisual = noiseField * 0.4 + turbulence * 0.3 + speckles * 0.3; + noiseVisual = clamp(noiseVisual, 0.0, 1.0); + + // Noise color - harsh, flickering + float3 noiseColor = mix(hotPink, uvViolet, turbulence); + noiseColor *= 0.8 + 0.2 * sin(time * 20.0 + noise(p * 10.0) * 10.0); + + // === BLEND based on HNR === + + // HNR determines the mix: 1.0 = pure harmonic, 0.0 = pure noise + float harmonicAmount = hnr; + float noiseAmount = 1.0 - hnr; + + // Apply reactivity to make transition more dramatic + harmonicAmount = pow(harmonicAmount, 1.0 / (1.0 + reactivity)); + + float3 harmonicContrib = harmonicColor * harmonicShapes * harmonicAmount; + float3 noiseContrib = noiseColor * noiseVisual * noiseAmount; + + float3 finalColor = harmonicContrib + noiseContrib; + + // Add center indicator showing current HNR + float indicator = smoothstep(0.25, 0.24, dist) - smoothstep(0.24, 0.23, dist); + float indicatorFill = smoothstep(0.23, 0.22, dist); + + // Split indicator by HNR + float harmonicSide = step(0.0, p.x); + float noiseSide = 1.0 - harmonicSide; + + finalColor += neonCyan * indicator * 0.3; + finalColor += neonCyan * indicatorFill * harmonicSide * hnr * 0.2; + finalColor += hotPink * indicatorFill * noiseSide * (1.0 - hnr) * 0.2; + + // Background glow + float bgGlow = exp(-dist * dist * 4.0); + float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist); + finalColor += bgColor * (1.0 - clamp(harmonicShapes + noiseVisual, 0.0, 1.0)); + + // Peak flash + if (uniforms.isPeak > 0.5) { + finalColor += float3(1.0) * uniforms.peakIntensity * 0.15 * exp(-dist * 3.0); + } + + return float4(finalColor, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/MandelbrotShader.metal b/PsytranceVisualizer/Rendering/Shaders/MandelbrotShader.metal new file mode 100644 index 0000000..0d86474 --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/MandelbrotShader.metal @@ -0,0 +1,121 @@ +// +// MandelbrotShader.metal +// PsytranceVisualizer +// +// Audio-reactive Mandelbrot fractal with zoom and color cycling +// + +#include +using namespace metal; + +#include "Common.metal" + +fragment float4 mandelbrotFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + + float subBass = uniforms.subBassEnergy; + float pump = uniforms.sidechainPump; + float centroid = uniforms.spectralCentroid; + + // Aspect ratio correction + float aspectRatio = resolution.x / resolution.y; + + // Map UV to complex plane + float2 c = (uv - 0.5) * 2.0; + c.x *= aspectRatio; + + // Audio-reactive zoom level + // Base zoom increases over time, modulated by sub-bass + float baseZoom = 1.0 + time * 0.02; + float audioZoom = subBass * 0.5 * (0.5 + reactivity * 0.5); + float zoom = pow(2.0, baseZoom + audioZoom); + + // Zoom center - drifts based on sidechain + float2 zoomCenter = float2(-0.7, 0.0); + zoomCenter.x += sin(time * 0.1) * 0.3 + pump * 0.1 * sin(time); + zoomCenter.y += cos(time * 0.13) * 0.2 + pump * 0.1 * cos(time); + + // Apply zoom + c = c / zoom + zoomCenter; + + // Mandelbrot iteration + float2 z = float2(0.0); + int maxIterations = int(50.0 + reactivity * 100.0); + int iterations = 0; + + float smoothIter = 0.0; + + for (int i = 0; i < 150; i++) { + if (i >= maxIterations) break; + + // z = z^2 + c + float2 zNew = float2( + z.x * z.x - z.y * z.y + c.x, + 2.0 * z.x * z.y + c.y + ); + z = zNew; + + float mag2 = dot(z, z); + if (mag2 > 256.0) { + // Smooth iteration count + smoothIter = float(i) - log2(log2(mag2)) + 4.0; + break; + } + + iterations = i; + } + + // Normalize iteration count + float normalizedIter = smoothIter / float(maxIterations); + + // Color based on iterations + float3 color; + + if (iterations >= maxIterations - 1) { + // Inside the set - deep color + color = deepPurple * (0.5 + 0.5 * subBass); + } else { + // Outside - color cycling based on iterations and audio + float colorPhase = normalizedIter + time * 0.1 + centroid; + + // Use psytrance palette with color rotation + color = psytrancePalette(colorPhase, time); + + // Modulate brightness by iteration depth + float brightness = 0.5 + 0.5 * sin(smoothIter * 0.3); + color *= brightness; + + // Add glow at boundary + float edgeFactor = 1.0 - normalizedIter; + edgeFactor = pow(edgeFactor, 3.0); + color = addGlow(color, edgeFactor * 0.5, neonCyan); + } + + // Sub-bass pulse effect + color *= 0.8 + 0.2 * subBass; + + // Sidechain breathing + float breathe = 1.0 + pump * 0.1; + color *= breathe; + + // Peak flash in bright areas + if (uniforms.isPeak > 0.5 && iterations < maxIterations - 1) { + color += neonMagenta * uniforms.peakIntensity * 0.2 * normalizedIter; + } + + // Subtle vignette + float2 vignetteuv = uv - 0.5; + float vignette = 1.0 - dot(vignetteuv, vignetteuv) * 0.5; + color *= vignette; + + return float4(color, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/MelSpectrogramShader.metal b/PsytranceVisualizer/Rendering/Shaders/MelSpectrogramShader.metal new file mode 100644 index 0000000..5b9a426 --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/MelSpectrogramShader.metal @@ -0,0 +1,95 @@ +// +// MelSpectrogramShader.metal +// PsytranceVisualizer +// +// Mel spectrogram with scrolling waterfall display +// + +#include +using namespace metal; + +#include "Common.metal" + +fragment float4 melSpectrogramFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + + // Configuration + const int numBands = 64; + const int historyLength = 128; + + // Map UV to mel band and history position + int bandIndex = int(uv.x * float(numBands)); + bandIndex = clamp(bandIndex, 0, numBands - 1); + + // Scrolling effect - newer data at bottom + float scrollOffset = fract(time * 0.5); // Scroll speed + float yPos = fract(uv.y + scrollOffset); + + // Get mel magnitude + float magnitude = melData[bandIndex]; + magnitude = magnitude * (0.5 + reactivity * 1.5); + magnitude = clamp(magnitude, 0.0, 1.0); + + // Create waterfall effect using history + int historyIndex = int(yPos * float(historyLength)); + historyIndex = clamp(historyIndex, 0, historyLength - 1); + + // Combine current and historical data for waterfall + float historicalValue = historyData[historyIndex]; + + // Blend between current magnitude and position-based intensity + float intensity = magnitude; + + // Add some variance based on band position + float bandPhase = float(bandIndex) / float(numBands); + intensity *= 0.8 + 0.2 * sin(bandPhase * 6.28318 + time); + + // Apply fade for older data (top of screen) + float ageFade = 1.0 - uv.y * 0.3; + intensity *= ageFade; + + // Generate color using heatmap + float3 color = heatmap(intensity); + + // Add frequency-dependent hue shift + float hueShift = bandPhase * 0.3; + color = psytrancePalette(intensity + hueShift, time); + + // Modulate by actual intensity + color *= 0.3 + intensity * 0.7; + + // Add grid lines for visual reference + float gridX = abs(fract(uv.x * float(numBands)) - 0.5) * 2.0; + float gridY = abs(fract(uv.y * 16.0) - 0.5) * 2.0; + + float gridLine = smoothstep(0.95, 1.0, gridX) + smoothstep(0.95, 1.0, gridY); + gridLine *= 0.1; + + color += float3(gridLine) * uvViolet; + + // Add glow on high energy + if (intensity > 0.7) { + float glow = (intensity - 0.7) / 0.3; + color = addGlow(color, glow * 0.5, neonCyan); + } + + // Peak flash + if (uniforms.isPeak > 0.5) { + color += neonMagenta * uniforms.peakIntensity * 0.15; + } + + // Sub-bass emphasis on lower bands + if (bandIndex < 8) { + color += uvViolet * uniforms.subBassEnergy * 0.3; + } + + return float4(color, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/SidechainPumpShader.metal b/PsytranceVisualizer/Rendering/Shaders/SidechainPumpShader.metal new file mode 100644 index 0000000..0bc7267 --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/SidechainPumpShader.metal @@ -0,0 +1,130 @@ +// +// SidechainPumpShader.metal +// PsytranceVisualizer +// +// Visualizes sidechain pumping with breathing zoom effect +// + +#include +using namespace metal; + +#include "Common.metal" + +fragment float4 sidechainPumpFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + + float pump = uniforms.sidechainPump; + float envelope = uniforms.sidechainEnvelope; + float subBass = uniforms.subBassEnergy; + + // Center and aspect ratio correction + float2 center = float2(0.5, 0.5); + float aspectRatio = resolution.x / resolution.y; + + float2 p = uv - center; + p.x *= aspectRatio; + + // Apply breathing zoom effect + float zoomAmount = 1.0 + pump * 0.3 * (0.5 + reactivity * 0.5); + p /= zoomAmount; + + // Radial distortion synchronized with pump + float dist = length(p); + float angle = atan2(p.y, p.x); + + // Pump-synced radial waves + float radialWave = sin(dist * 15.0 - time * 3.0 + envelope * 10.0); + radialWave *= pump * 0.3; + + // Apply distortion + float2 distortedP = p; + distortedP *= 1.0 + radialWave * 0.1; + + // Create concentric pulse rings + float rings = 0.0; + const int numRings = 5; + + for (int i = 0; i < numRings; i++) { + float ringPhase = fract(time * 0.5 + float(i) * 0.2 - envelope * 0.5); + float ringRadius = ringPhase * 0.6; + float ringWidth = 0.02 + pump * 0.03; + + float ringDist = abs(dist - ringRadius); + float ring = exp(-ringDist * ringDist / (ringWidth * ringWidth)); + ring *= 1.0 - ringPhase; // Fade out as it expands + ring *= pump; + + rings += ring; + } + + // Breathing glow in center + float breathIntensity = 0.5 + 0.5 * sin(time * 4.0 + envelope * 6.28318); + breathIntensity *= pump; + + float centerGlow = exp(-dist * dist * 8.0); + centerGlow *= breathIntensity; + + // Color based on pump phase + float3 pumpColor = mix(uvViolet, neonMagenta, envelope); + float3 ringColor = mix(neonCyan, hotPink, pump); + + // Background pattern - angular sectors that pulse + float sectors = 8.0; + float sectorAngle = fract(angle / (2.0 * 3.14159) * sectors); + float sectorPulse = smoothstep(0.4, 0.5, sectorAngle) - smoothstep(0.5, 0.6, sectorAngle); + sectorPulse *= pump * 0.3; + sectorPulse *= exp(-dist * 3.0); + + // Spiral pattern + float spiral = fract(angle / (2.0 * 3.14159) * 3.0 + dist * 5.0 - time * 0.5); + spiral = smoothstep(0.4, 0.5, spiral) - smoothstep(0.5, 0.6, spiral); + spiral *= pump * 0.2; + spiral *= exp(-dist * 2.0); + + // Compose final color + float3 finalColor = float3(0.0); + + // Base gradient + float3 bgGradient = mix(deepPurple, uvViolet * 0.3, dist); + finalColor += bgGradient; + + // Add rings + finalColor += ringColor * rings; + + // Add center glow + finalColor += pumpColor * centerGlow; + + // Add sector pulse + finalColor += neonGreen * sectorPulse; + + // Add spiral + finalColor += electricBlue * spiral; + + // Screen flash on strong pump + if (pump > 0.7) { + float flash = (pump - 0.7) / 0.3; + flash *= 0.2; + finalColor += neonMagenta * flash; + } + + // Peak highlight + if (uniforms.isPeak > 0.5) { + float peakFlash = uniforms.peakIntensity * 0.2; + finalColor += float3(1.0) * peakFlash * exp(-dist * 5.0); + } + + // Vignette + float vignette = 1.0 - smoothstep(0.4, 0.8, dist); + finalColor *= 0.7 + vignette * 0.3; + + return float4(finalColor, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/SubBassShader.metal b/PsytranceVisualizer/Rendering/Shaders/SubBassShader.metal new file mode 100644 index 0000000..e2539d7 --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/SubBassShader.metal @@ -0,0 +1,116 @@ +// +// SubBassShader.metal +// PsytranceVisualizer +// +// Pulsating rings visualizing sub-bass energy below 100Hz +// + +#include +using namespace metal; + +#include "Common.metal" + +fragment float4 subBassFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + float subBass = uniforms.subBassEnergy; + + // Center coordinates + float2 center = float2(0.5, 0.5); + float aspectRatio = resolution.x / resolution.y; + + // Correct for aspect ratio + float2 p = uv - center; + p.x *= aspectRatio; + + float dist = length(p); + float angle = atan2(p.y, p.x); + + // Main pulsating circle + float baseRadius = 0.15; + float pulseAmount = subBass * (0.5 + reactivity * 0.5); + float mainRadius = baseRadius + pulseAmount * 0.2; + + // Add wobble based on angle + float wobble = sin(angle * 4.0 + time * 2.0) * 0.02 * subBass; + mainRadius += wobble; + + // Core circle + float coreDist = abs(dist - mainRadius); + float coreGlow = exp(-coreDist * coreDist * 200.0); + + // Inner fill with gradient + float innerFill = smoothstep(mainRadius, mainRadius * 0.3, dist); + innerFill *= 0.5 + 0.5 * subBass; + + // Expanding rings + const int numRings = 6; + float ringIntensity = 0.0; + + for (int i = 0; i < numRings; i++) { + // Each ring expands outward over time + float ringPhase = fract(time * 0.3 - float(i) * 0.15); + float ringRadius = mainRadius + ringPhase * 0.5; + + // Get historical sub-bass value for this ring + int histIndex = clamp(int(ringPhase * 64.0), 0, 63); + float histValue = historyData[histIndex]; + + // Ring thickness based on historical energy + float thickness = 0.005 + histValue * 0.01; + float ringDist = abs(dist - ringRadius); + + // Ring visibility + float ring = exp(-ringDist * ringDist / (thickness * thickness)); + ring *= (1.0 - ringPhase); // Fade as it expands + ring *= histValue; // Intensity based on history + + ringIntensity += ring; + } + + // Color composition + float3 coreColor = mix(uvViolet, neonMagenta, subBass); + float3 ringColor = mix(neonMagenta, hotPink, 0.5 + 0.5 * sin(time)); + + float3 finalColor = float3(0.0); + + // Add core + finalColor += coreColor * (innerFill + coreGlow * 2.0); + + // Add rings + finalColor += ringColor * ringIntensity * 0.8; + + // Add central glow + float centerGlow = exp(-dist * dist * 10.0) * subBass; + finalColor += uvViolet * centerGlow * 0.5; + + // Add angular rays on peaks + if (uniforms.isPeak > 0.5) { + float rays = abs(sin(angle * 8.0 + time * 5.0)); + rays = pow(rays, 4.0) * exp(-dist * 2.0); + rays *= uniforms.peakIntensity; + finalColor += neonCyan * rays * 0.5; + } + + // Outer vignette + float vignette = 1.0 - smoothstep(0.3, 0.8, dist); + finalColor *= vignette; + + // Background pulse + float bgPulse = subBass * 0.1; + finalColor += deepPurple * bgPulse; + + // Add noise texture for organic feel + float noiseVal = noise(p * 20.0 + time); + finalColor += uvViolet * noiseVal * 0.02 * subBass; + + return float4(finalColor, 1.0); +} diff --git a/PsytranceVisualizer/Rendering/Shaders/TunnelWarpShader.metal b/PsytranceVisualizer/Rendering/Shaders/TunnelWarpShader.metal new file mode 100644 index 0000000..37ba32c --- /dev/null +++ b/PsytranceVisualizer/Rendering/Shaders/TunnelWarpShader.metal @@ -0,0 +1,136 @@ +// +// TunnelWarpShader.metal +// PsytranceVisualizer +// +// Infinite tunnel effect with warp distortion +// + +#include +using namespace metal; + +#include "Common.metal" + +fragment float4 tunnelWarpFragment( + VertexOut in [[stage_in]], + constant ShaderUniforms& uniforms [[buffer(0)]], + constant float* fftData [[buffer(1)]], + constant float* melData [[buffer(2)]], + constant float* historyData [[buffer(3)]] +) { + float2 uv = in.uv; + float2 resolution = uniforms.resolution; + float time = uniforms.time; + float reactivity = uniforms.reactivity; + + float subBass = uniforms.subBassEnergy; + float pump = uniforms.sidechainPump; + float hnr = uniforms.hnrRatio; + + // Center and aspect correction + float aspectRatio = resolution.x / resolution.y; + float2 p = (uv - 0.5) * 2.0; + p.x *= aspectRatio; + + // Convert to polar coordinates for tunnel + float dist = length(p); + float angle = atan2(p.y, p.x); + + // Avoid division by zero at center + dist = max(dist, 0.001); + + // Tunnel depth (inverse of distance) + float depth = 1.0 / dist; + + // Speed controlled by sub-bass + float baseSpeed = 2.0; + float audioSpeed = subBass * 3.0 * (0.5 + reactivity * 0.5); + float speed = baseSpeed + audioSpeed; + + // Warp distortion from sidechain pump + float warpAmount = pump * 0.5; + depth += sin(angle * 4.0 + time * 2.0) * warpAmount * 0.5; + angle += sin(depth * 2.0 + time) * warpAmount * 0.3; + + // Create tunnel coordinates + float2 tunnelUV = float2( + angle / (2.0 * 3.14159) + 0.5, // Angular coordinate [0, 1] + depth + time * speed // Depth with movement + ); + + // === TUNNEL WALL PATTERNS === + + // Hexagonal grid pattern + float2 hexUV = tunnelUV * float2(8.0, 2.0); + float2 hexCell = floor(hexUV); + float2 hexFrac = fract(hexUV); + + // Offset every other row + if (fmod(hexCell.y, 2.0) > 0.5) { + hexFrac.x = fract(hexFrac.x + 0.5); + } + + float hexDist = length(hexFrac - 0.5); + float hexPattern = smoothstep(0.4, 0.35, hexDist); + + // Add concentric rings + float rings = sin(tunnelUV.y * 20.0) * 0.5 + 0.5; + rings = smoothstep(0.3, 0.7, rings); + + // Angular segments + float segments = 8.0; + float angularLines = abs(sin(angle * segments)); + angularLines = smoothstep(0.95, 1.0, angularLines); + + // Combine patterns + float pattern = hexPattern * 0.5 + rings * 0.3 + angularLines * 0.2; + + // === COLORING === + + // Base color cycles with depth and time + float colorPhase = tunnelUV.y * 0.1 + time * 0.2; + float3 tunnelColor = psytrancePalette(colorPhase, time); + + // Depth fog (darker towards center/infinity) + float fog = exp(-dist * 2.0); + tunnelColor *= fog; + + // Pattern overlay + float3 patternColor = mix(uvViolet, neonCyan, rings); + tunnelColor = mix(tunnelColor, patternColor, pattern * 0.5); + + // Edge glow (bright at tunnel edges) + float edgeGlow = exp(-dist * 5.0); + tunnelColor = addGlow(tunnelColor, (1.0 - edgeGlow) * 0.3, neonMagenta); + + // Center light (looking into the tunnel) + float centerLight = exp(-dist * dist * 50.0); + tunnelColor += float3(1.0) * centerLight * 0.5; + + // HNR affects pattern complexity + float patternIntensity = hnr; + tunnelColor *= 0.7 + patternIntensity * 0.3; + + // Add noise for texture + float noiseVal = noise(tunnelUV * 10.0 + time); + tunnelColor += uvViolet * noiseVal * 0.1; + + // Pump flash + if (pump > 0.5) { + float pumpFlash = (pump - 0.5) * 2.0; + tunnelColor += neonMagenta * pumpFlash * 0.2; + } + + // Peak flash + if (uniforms.isPeak > 0.5) { + float peakFlash = uniforms.peakIntensity; + tunnelColor += float3(1.0) * peakFlash * 0.15 * (1.0 - edgeGlow); + } + + // Speed lines effect + float speedLines = fract(tunnelUV.y * 50.0 - time * speed * 2.0); + speedLines = smoothstep(0.95, 1.0, speedLines); + speedLines *= subBass * 0.5; + tunnelColor += neonCyan * speedLines; + + return float4(tunnelColor, 1.0); +} diff --git a/PsytranceVisualizer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/PsytranceVisualizer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..967f12a --- /dev/null +++ b/PsytranceVisualizer/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PsytranceVisualizer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/PsytranceVisualizer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/PsytranceVisualizer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PsytranceVisualizer/Resources/Assets.xcassets/Contents.json b/PsytranceVisualizer/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PsytranceVisualizer/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PsytranceVisualizer/Resources/Info.plist b/PsytranceVisualizer/Resources/Info.plist new file mode 100644 index 0000000..660cf54 --- /dev/null +++ b/PsytranceVisualizer/Resources/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.music + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright © 2024. All rights reserved. + NSMicrophoneUsageDescription + Psytrance Visualizer needs access to your audio input to visualize music in real-time. You can use a virtual audio device like BlackHole to route system audio. + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/PsytranceVisualizer/Resources/PsytranceVisualizer.entitlements b/PsytranceVisualizer/Resources/PsytranceVisualizer.entitlements new file mode 100644 index 0000000..acf78e7 --- /dev/null +++ b/PsytranceVisualizer/Resources/PsytranceVisualizer.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.audio-input + + com.apple.security.files.user-selected.read-write + + + diff --git a/PsytranceVisualizer/UI/ControlPanel.swift b/PsytranceVisualizer/UI/ControlPanel.swift new file mode 100644 index 0000000..1f32145 --- /dev/null +++ b/PsytranceVisualizer/UI/ControlPanel.swift @@ -0,0 +1,315 @@ +// +// ControlPanel.swift +// PsytranceVisualizer +// +// Auto-hiding control panel with audio and visualization settings +// + +import AppKit +import Combine + +/// Delegate protocol for control panel actions +protocol ControlPanelDelegate: AnyObject { + func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String) + func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int) + func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode) + func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float) + func controlPanelDidRequestFullscreen(_ panel: ControlPanel) +} + +/// Auto-hiding control panel overlay +final class ControlPanel: NSView { + // MARK: - Properties + + weak var delegate: ControlPanelDelegate? + + private var isVisible = true + private var hideTimer: Timer? + private let hideDelay: TimeInterval = 3.0 + + private var audioDevices: [AudioDevice] = [] + private var selectedMode: VisualizationMode = .fftClassic + + // MARK: - UI Elements + + private let containerView = NSVisualEffectView() + private let devicePopup = NSPopUpButton() + private let bufferSizePopup = NSPopUpButton() + private let modeSegment = NSSegmentedControl() + private let reactivitySlider = NSSlider() + private let reactivityLabel = NSTextField(labelWithString: "Reactivity") + private let fullscreenButton = NSButton() + + // MARK: - Layout Constants + + private let panelHeight: CGFloat = 60 + private let padding: CGFloat = 12 + private let elementHeight: CGFloat = 24 + + // MARK: - Initialization + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + setupConstraints() + startHideTimer() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + // Container with vibrancy effect + containerView.material = .hudWindow + containerView.blendingMode = .behindWindow + containerView.state = .active + containerView.wantsLayer = true + containerView.layer?.cornerRadius = 12 + containerView.layer?.masksToBounds = true + addSubview(containerView) + + // Device popup + devicePopup.target = self + devicePopup.action = #selector(deviceChanged) + devicePopup.controlSize = .small + devicePopup.font = .systemFont(ofSize: 11) + containerView.addSubview(devicePopup) + + // Buffer size popup + bufferSizePopup.target = self + bufferSizePopup.action = #selector(bufferSizeChanged) + bufferSizePopup.controlSize = .small + bufferSizePopup.font = .systemFont(ofSize: 11) + bufferSizePopup.addItems(withTitles: ["512", "1024"]) + bufferSizePopup.selectItem(withTitle: "1024") + containerView.addSubview(bufferSizePopup) + + // Mode segment control + modeSegment.segmentCount = 8 + for mode in VisualizationMode.allCases { + modeSegment.setLabel(mode.shortcut, forSegment: mode.rawValue - 1) + modeSegment.setToolTip(mode.displayName, forSegment: mode.rawValue - 1) + } + modeSegment.selectedSegment = 0 + modeSegment.target = self + modeSegment.action = #selector(modeChanged) + modeSegment.controlSize = .small + modeSegment.segmentStyle = .capsule + containerView.addSubview(modeSegment) + + // Reactivity label + reactivityLabel.font = .systemFont(ofSize: 10) + reactivityLabel.textColor = .secondaryLabelColor + containerView.addSubview(reactivityLabel) + + // Reactivity slider + reactivitySlider.minValue = 0.0 + reactivitySlider.maxValue = 1.0 + reactivitySlider.doubleValue = 0.5 + reactivitySlider.target = self + reactivitySlider.action = #selector(reactivityChanged) + reactivitySlider.controlSize = .small + containerView.addSubview(reactivitySlider) + + // Fullscreen button + fullscreenButton.title = "⛶" + fullscreenButton.bezelStyle = .accessoryBarAction + fullscreenButton.target = self + fullscreenButton.action = #selector(fullscreenClicked) + fullscreenButton.toolTip = "Toggle Fullscreen (F)" + containerView.addSubview(fullscreenButton) + + // Set colors + applyPsytranceTheme() + } + + private func applyPsytranceTheme() { + // Custom appearance for psytrance aesthetic + containerView.appearance = NSAppearance(named: .darkAqua) + } + + private func setupConstraints() { + containerView.translatesAutoresizingMaskIntoConstraints = false + devicePopup.translatesAutoresizingMaskIntoConstraints = false + bufferSizePopup.translatesAutoresizingMaskIntoConstraints = false + modeSegment.translatesAutoresizingMaskIntoConstraints = false + reactivityLabel.translatesAutoresizingMaskIntoConstraints = false + reactivitySlider.translatesAutoresizingMaskIntoConstraints = false + fullscreenButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + // Container + containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding), + containerView.heightAnchor.constraint(equalToConstant: panelHeight), + + // Device popup + devicePopup.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), + devicePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + devicePopup.widthAnchor.constraint(equalToConstant: 150), + + // Buffer size popup + bufferSizePopup.leadingAnchor.constraint(equalTo: devicePopup.trailingAnchor, constant: 8), + bufferSizePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + bufferSizePopup.widthAnchor.constraint(equalToConstant: 60), + + // Mode segment + modeSegment.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + modeSegment.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + + // Reactivity label + reactivityLabel.trailingAnchor.constraint(equalTo: reactivitySlider.leadingAnchor, constant: -4), + reactivityLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + + // Reactivity slider + reactivitySlider.trailingAnchor.constraint(equalTo: fullscreenButton.leadingAnchor, constant: -padding), + reactivitySlider.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + reactivitySlider.widthAnchor.constraint(equalToConstant: 80), + + // Fullscreen button + fullscreenButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), + fullscreenButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + } + + // MARK: - Public Methods + + /// Updates the list of available audio devices + func updateDevices(_ devices: [AudioDevice], selectedUID: String?) { + audioDevices = devices + devicePopup.removeAllItems() + + for device in devices { + devicePopup.addItem(withTitle: device.name) + devicePopup.lastItem?.representedObject = device.uid + } + + if let uid = selectedUID, + let index = devices.firstIndex(where: { $0.uid == uid }) { + devicePopup.selectItem(at: index) + } + } + + /// Updates the selected buffer size + func updateBufferSize(_ size: Int) { + bufferSizePopup.selectItem(withTitle: "\(size)") + } + + /// Updates the selected visualization mode + func updateMode(_ mode: VisualizationMode) { + selectedMode = mode + modeSegment.selectedSegment = mode.rawValue - 1 + } + + /// Updates the reactivity slider + func updateReactivity(_ value: Float) { + reactivitySlider.doubleValue = Double(value) + } + + /// Shows the control panel + func show(animated: Bool = true) { + guard !isVisible else { return } + isVisible = true + + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + self.animator().alphaValue = 1.0 + } + } else { + alphaValue = 1.0 + } + + startHideTimer() + } + + /// Hides the control panel + func hide(animated: Bool = true) { + guard isVisible else { return } + isVisible = false + hideTimer?.invalidate() + + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + self.animator().alphaValue = 0.0 + } + } else { + alphaValue = 0.0 + } + } + + /// Resets the hide timer (call on mouse movement) + func resetHideTimer() { + show() + startHideTimer() + } + + // MARK: - Private Methods + + private func startHideTimer() { + hideTimer?.invalidate() + hideTimer = Timer.scheduledTimer(withTimeInterval: hideDelay, repeats: false) { [weak self] _ in + self?.hide() + } + } + + // MARK: - Actions + + @objc private func deviceChanged() { + guard let uid = devicePopup.selectedItem?.representedObject as? String else { return } + delegate?.controlPanel(self, didSelectDevice: uid) + } + + @objc private func bufferSizeChanged() { + guard let title = bufferSizePopup.selectedItem?.title, + let size = Int(title) else { return } + delegate?.controlPanel(self, didSelectBufferSize: size) + } + + @objc private func modeChanged() { + let modeIndex = modeSegment.selectedSegment + 1 + guard let mode = VisualizationMode(rawValue: modeIndex) else { return } + selectedMode = mode + delegate?.controlPanel(self, didSelectMode: mode) + } + + @objc private func reactivityChanged() { + let value = Float(reactivitySlider.doubleValue) + delegate?.controlPanel(self, didChangeReactivity: value) + } + + @objc private func fullscreenClicked() { + delegate?.controlPanelDidRequestFullscreen(self) + } + + // MARK: - Mouse Tracking + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // Remove existing tracking areas + for area in trackingAreas { + removeTrackingArea(area) + } + + // Add new tracking area + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeAlways] + let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) + addTrackingArea(trackingArea) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + resetHideTimer() + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + show() + } +} diff --git a/PsytranceVisualizer/UI/MainWindow.swift b/PsytranceVisualizer/UI/MainWindow.swift new file mode 100644 index 0000000..ef42d48 --- /dev/null +++ b/PsytranceVisualizer/UI/MainWindow.swift @@ -0,0 +1,323 @@ +// +// MainWindow.swift +// PsytranceVisualizer +// +// Main application window with keyboard handling +// + +import AppKit +import Combine + +/// Main window controller for the visualizer +final class MainWindowController: NSWindowController { + // MARK: - Properties + + private var visualizerView: VisualizerView! + private var controlPanel: ControlPanel! + + private var audioManager: AudioInputManager! + private var dspEngine: DSPEngine! + private var settingsManager: SettingsManager { .shared } + + private var cancellables = Set() + private var displayLink: CVDisplayLink? + + // MARK: - Initialization + + convenience init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1280, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.title = "Psytrance Visualizer" + window.minSize = NSSize(width: 800, height: 600) + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.backgroundColor = .black + window.collectionBehavior = [.fullScreenPrimary] + + // Restore window frame if saved + if let savedFrame = SettingsManager.shared.settings.windowFrame?.cgRect { + window.setFrame(savedFrame, display: false) + } else { + window.center() + } + + self.init(window: window) + + setupContent() + setupAudio() + setupKeyboardHandling() + restoreSettings() + } + + // MARK: - Setup + + private func setupContent() { + guard let contentView = window?.contentView else { return } + + // Visualizer view (fills entire window) + visualizerView = VisualizerView() + visualizerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(visualizerView) + + // Control panel (overlay at bottom) + controlPanel = ControlPanel() + controlPanel.translatesAutoresizingMaskIntoConstraints = false + controlPanel.delegate = self + contentView.addSubview(controlPanel) + + NSLayoutConstraint.activate([ + // Visualizer fills entire window + visualizerView.topAnchor.constraint(equalTo: contentView.topAnchor), + visualizerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + visualizerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + visualizerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + // Control panel at bottom + controlPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + controlPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + controlPanel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + controlPanel.heightAnchor.constraint(equalToConstant: 90), + ]) + + // Mouse tracking for control panel + setupMouseTracking() + } + + private func setupAudio() { + audioManager = AudioInputManager() + dspEngine = DSPEngine(bufferSize: settingsManager.settings.bufferSize) + + // Audio buffer callback + audioManager.onAudioBuffer = { [weak self] buffer in + guard let self = self else { return } + let analysisData = self.dspEngine.process(buffer: buffer) + + DispatchQueue.main.async { + self.visualizerView.updateAudioData(analysisData) + } + } + + // Update control panel when devices change + audioManager.$availableDevices + .receive(on: DispatchQueue.main) + .sink { [weak self] devices in + self?.controlPanel.updateDevices( + devices, + selectedUID: self?.settingsManager.settings.selectedAudioDeviceUID + ) + } + .store(in: &cancellables) + + // Start audio + audioManager.start() + } + + private func setupKeyboardHandling() { + // Monitor for key events + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if self?.handleKeyDown(event) == true { + return nil // Event handled + } + return event + } + } + + private func setupMouseTracking() { + guard let contentView = window?.contentView else { return } + + let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .inVisibleRect] + let trackingArea = NSTrackingArea( + rect: contentView.bounds, + options: options, + owner: self, + userInfo: nil + ) + contentView.addTrackingArea(trackingArea) + } + + private func restoreSettings() { + let settings = settingsManager.settings + + // Restore visualization mode + if let mode = VisualizationMode(rawValue: settings.lastVisualizationMode) { + visualizerView.setVisualizationMode(mode) + controlPanel.updateMode(mode) + } + + // Restore reactivity + visualizerView.setReactivity(settings.reactivity) + dspEngine.setReactivity(settings.reactivity) + controlPanel.updateReactivity(settings.reactivity) + + // Restore buffer size + dspEngine.setBufferSize(settings.bufferSize) + audioManager.setBufferSize(settings.bufferSize) + controlPanel.updateBufferSize(settings.bufferSize) + + // Restore audio device + if let deviceUID = settings.selectedAudioDeviceUID { + audioManager.selectDevice(uid: deviceUID) + } + + // Restore fullscreen state + if settings.isFullscreen { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.window?.toggleFullScreen(nil) + } + } + } + + // MARK: - Keyboard Handling + + private func handleKeyDown(_ event: NSEvent) -> Bool { + // Check for visualization mode shortcuts (1-8) + if let mode = VisualizationMode.fromKeyCode(event.keyCode) { + setVisualizationMode(mode) + return true + } + + // Other keyboard shortcuts + switch event.keyCode { + case 3: // F key + toggleFullscreen() + return true + case 53: // Escape + if window?.styleMask.contains(.fullScreen) == true { + window?.toggleFullScreen(nil) + } + return true + case 49: // Space + // Toggle pause (could be implemented) + return true + default: + break + } + + // Cmd+F for fullscreen + if event.modifierFlags.contains(.command) && event.keyCode == 3 { + toggleFullscreen() + return true + } + + return false + } + + // MARK: - Mode Switching + + private func setVisualizationMode(_ mode: VisualizationMode) { + visualizerView.setVisualizationMode(mode) + controlPanel.updateMode(mode) + settingsManager.setVisualizationMode(mode) + } + + // MARK: - Fullscreen + + private func toggleFullscreen() { + window?.toggleFullScreen(nil) + } + + // MARK: - Mouse Events + + override func mouseMoved(with event: NSEvent) { + controlPanel.resetHideTimer() + } + + // MARK: - Window Events + + override func windowDidLoad() { + super.windowDidLoad() + + // Save window frame on move/resize + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResize), + name: NSWindow.didResizeNotification, + object: window + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidMove), + name: NSWindow.didMoveNotification, + object: window + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidEnterFullScreen), + name: NSWindow.didEnterFullScreenNotification, + object: window + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidExitFullScreen), + name: NSWindow.didExitFullScreenNotification, + object: window + ) + } + + @objc private func windowDidResize(_ notification: Notification) { + if let frame = window?.frame { + settingsManager.setWindowFrame(frame) + } + } + + @objc private func windowDidMove(_ notification: Notification) { + if let frame = window?.frame { + settingsManager.setWindowFrame(frame) + } + } + + @objc private func windowDidEnterFullScreen(_ notification: Notification) { + settingsManager.setFullscreen(true) + controlPanel.hide() + } + + @objc private func windowDidExitFullScreen(_ notification: Notification) { + settingsManager.setFullscreen(false) + controlPanel.show() + } + + // MARK: - Cleanup + + deinit { + audioManager.stop() + settingsManager.saveNow() + } +} + +// MARK: - ControlPanelDelegate + +extension MainWindowController: ControlPanelDelegate { + func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String) { + audioManager.selectDevice(uid: uid) + settingsManager.setAudioDevice(uid: uid) + } + + func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int) { + audioManager.setBufferSize(size) + dspEngine.setBufferSize(size) + settingsManager.setBufferSize(size) + } + + func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode) { + setVisualizationMode(mode) + } + + func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float) { + visualizerView.setReactivity(value) + dspEngine.setReactivity(value) + settingsManager.setReactivity(value) + } + + func controlPanelDidRequestFullscreen(_ panel: ControlPanel) { + toggleFullscreen() + } +} diff --git a/PsytranceVisualizer/UI/VisualizerView.swift b/PsytranceVisualizer/UI/VisualizerView.swift new file mode 100644 index 0000000..0f0c609 --- /dev/null +++ b/PsytranceVisualizer/UI/VisualizerView.swift @@ -0,0 +1,122 @@ +// +// VisualizerView.swift +// PsytranceVisualizer +// +// MTKView subclass for rendering visualizations +// + +import MetalKit +import Combine + +/// MTKView subclass that displays audio-reactive visualizations +final class VisualizerView: MTKView { + // MARK: - Properties + + private var renderer: MetalRenderer? + private var cancellables = Set() + + // MARK: - Initialization + + init() { + // Get default Metal device + guard let device = MTLCreateSystemDefaultDevice() else { + fatalError("Metal is not supported on this device") + } + + super.init(frame: .zero, device: device) + + configure() + setupRenderer() + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configuration + + private func configure() { + // Background color + clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + + // Color format + colorPixelFormat = .bgra8Unorm + + // Enable display link for smooth rendering + isPaused = false + enableSetNeedsDisplay = false + + // Use display refresh rate + preferredFramesPerSecond = 120 // Will cap to display refresh + + // Layer configuration + layer?.isOpaque = true + + // Allow high DPI + layer?.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + } + + private func setupRenderer() { + guard let device = device else { return } + + renderer = MetalRenderer(device: device) + delegate = renderer + + // Initial size update + if let renderer = renderer { + let size = drawableSize + renderer.mtkView(self, drawableSizeWillChange: size) + } + } + + // MARK: - Public Methods + + /// Returns the Metal renderer + func getRenderer() -> MetalRenderer? { + return renderer + } + + /// Updates audio data for visualization + func updateAudioData(_ data: AudioAnalysisData) { + renderer?.updateAudioData(data) + } + + /// Sets the visualization mode + func setVisualizationMode(_ mode: VisualizationMode) { + renderer?.setVisualizationMode(mode) + } + + /// Sets reactivity value + func setReactivity(_ value: Float) { + renderer?.setReactivity(value) + } + + /// Gets current visualization mode + var currentMode: VisualizationMode { + renderer?.currentMode ?? .fftClassic + } +} + +// MARK: - SwiftUI Bridge + +#if canImport(SwiftUI) +import SwiftUI + +/// SwiftUI wrapper for VisualizerView +struct VisualizerViewRepresentable: NSViewRepresentable { + @Binding var audioData: AudioAnalysisData + @Binding var mode: VisualizationMode + @Binding var reactivity: Float + + func makeNSView(context: Context) -> VisualizerView { + let view = VisualizerView() + return view + } + + func updateNSView(_ nsView: VisualizerView, context: Context) { + nsView.updateAudioData(audioData) + nsView.setVisualizationMode(mode) + nsView.setReactivity(reactivity) + } +} +#endif diff --git a/PsytranceVisualizer/Utilities/ColorPalette.swift b/PsytranceVisualizer/Utilities/ColorPalette.swift new file mode 100644 index 0000000..b243bba --- /dev/null +++ b/PsytranceVisualizer/Utilities/ColorPalette.swift @@ -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 Colors (for Metal shaders) + + struct Metal { + static let neonMagenta = SIMD3(1.0, 0.0, 1.0) + static let neonCyan = SIMD3(0.0, 1.0, 1.0) + static let neonGreen = SIMD3(0.224, 1.0, 0.078) + static let uvViolet = SIMD3(0.482, 0.0, 1.0) + static let background = SIMD3(0.0, 0.0, 0.0) + static let darkPurple = SIMD3(0.1, 0.0, 0.15) + static let hotPink = SIMD3(1.0, 0.2, 0.6) + static let electricBlue = SIMD3(0.0, 0.5, 1.0) + + /// Array of all palette colors for cycling + static let palette: [SIMD3] = [ + neonMagenta, + neonCyan, + neonGreen, + uvViolet, + hotPink, + electricBlue + ] + + /// Get color from palette by index (wraps around) + static func color(at index: Int) -> SIMD3 { + palette[index % palette.count] + } + + /// Interpolate between two colors + static func lerp(_ a: SIMD3, _ b: SIMD3, t: Float) -> SIMD3 { + a + (b - a) * t + } + + /// Get rainbow color from normalized value (0-1) + static func rainbow(_ t: Float) -> SIMD3 { + 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 for Metal + var simd3: SIMD3 { + guard let rgb = usingColorSpace(.deviceRGB) else { + return SIMD3(0, 0, 0) + } + return SIMD3( + Float(rgb.redComponent), + Float(rgb.greenComponent), + Float(rgb.blueComponent) + ) + } + + /// Converts NSColor to SIMD4 for Metal (with alpha) + var simd4: SIMD4 { + guard let rgb = usingColorSpace(.deviceRGB) else { + return SIMD4(0, 0, 0, 1) + } + return SIMD4( + Float(rgb.redComponent), + Float(rgb.greenComponent), + Float(rgb.blueComponent), + Float(rgb.alphaComponent) + ) + } +} diff --git a/PsytranceVisualizer/Utilities/SettingsManager.swift b/PsytranceVisualizer/Utilities/SettingsManager.swift new file mode 100644 index 0000000..e37c85b --- /dev/null +++ b/PsytranceVisualizer/Utilities/SettingsManager.swift @@ -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 + } +}