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