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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user