Add Psytrance Visualizer macOS app with Metal rendering

A complete audio-reactive visualizer for psytrance music featuring:

Audio Analysis (DSPEngine):
- FFT spectrum analysis via Accelerate/vDSP
- 64-band Mel spectrogram
- Sub-bass energy extraction (<100Hz)
- Automatic sidechain pump detection
- Harmonic-to-Noise ratio (HNR) calculation
- Peak/transient detection

8 Visualization Modes (Metal Shaders):
1. FFT Classic - Frequency spectrum bars with glow
2. Mel Spectrogram - Waterfall display
3. Sub-Bass - Pulsating rings
4. Sidechain Pump - Breathing zoom effect
5. Harmonic/Noise - Geometric vs chaotic particles
6. Mandelbrot - Audio-reactive fractal zoom
7. Tunnel Warp - Infinite tunnel with distortion
8. DMT Geometry - Sacred geometry patterns

Features:
- Selectable audio input device (BlackHole support)
- Configurable buffer size (512/1024)
- Reactivity slider for visual intensity
- Auto-hiding control panel
- Fullscreen support with keyboard shortcuts (1-8, F, ESC)
- Persistent settings via UserDefaults
- Psytrance-inspired neon/UV color palette
This commit is contained in:
Claude
2025-12-22 21:36:45 +00:00
parent b607a9cd8a
commit a22c238dc4
29 changed files with 4780 additions and 0 deletions
+315
View File
@@ -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()
}
}
+323
View File
@@ -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<AnyCancellable>()
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()
}
}
+122
View File
@@ -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<AnyCancellable>()
// 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