Files
Ai/PsytranceVisualizer/UI/ControlPanel.swift
Claude a22c238dc4 Add Psytrance Visualizer macOS app with Metal rendering
A complete audio-reactive visualizer for psytrance music featuring:

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

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

Features:
- Selectable audio input device (BlackHole support)
- Configurable buffer size (512/1024)
- Reactivity slider for visual intensity
- Auto-hiding control panel
- Fullscreen support with keyboard shortcuts (1-8, F, ESC)
- Persistent settings via UserDefaults
- Psytrance-inspired neon/UV color palette
2025-12-22 21:36:45 +00:00

316 lines
11 KiB
Swift

//
// 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()
}
}