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
@@ -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<AudioDeviceID>.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<AudioBufferList>.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<CFString>.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<CFString>.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<CFString>.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<AudioDeviceID>.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
)
}
}
+468
View File
@@ -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..<count {
windowedBuffer[i] = buffer[i] * window[i]
}
// Prepare for DFT (separate into real and imaginary)
for i in 0..<fftSize {
realPart[i] = windowedBuffer[i]
imagPart[i] = 0
}
// Perform DFT
var outputReal = [Float](repeating: 0, count: fftSize)
var outputImag = [Float](repeating: 0, count: fftSize)
vDSP_DFT_Execute(setup, realPart, imagPart, &outputReal, &outputImag)
// Calculate magnitudes
let halfSize = fftSize / 2
var newMagnitudes = [Float](repeating: 0, count: halfSize)
for i in 0..<halfSize {
let real = outputReal[i]
let imag = outputImag[i]
newMagnitudes[i] = sqrt(real * real + imag * imag) / Float(fftSize)
}
// Apply smoothing
for i in 0..<halfSize {
magnitudes[i] = magnitudes[i] * smoothingFactor + newMagnitudes[i] * (1.0 - smoothingFactor)
}
previousMagnitudes = magnitudes
return magnitudes
}
// MARK: - Mel Filterbank
private func buildMelFilterbank() {
let halfFFT = fftSize / 2
let nyquist = sampleRate / 2.0
// Mel scale conversion
func hzToMel(_ hz: Float) -> 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..<melBandCount + 2 {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(melBandCount + 1)
}
// Convert back to Hz
var hzPoints = melPoints.map { melToHz($0) }
// Convert to FFT bins
var binPoints = hzPoints.map { Int($0 / nyquist * Float(halfFFT)) }
// Build triangular filters
melFilterbank = []
for m in 1...melBandCount {
var filter = [Float](repeating: 0, count: halfFFT)
let startBin = binPoints[m - 1]
let centerBin = binPoints[m]
let endBin = binPoints[m + 1]
// Rising edge
for k in startBin..<centerBin {
if centerBin != startBin {
filter[k] = Float(k - startBin) / Float(centerBin - startBin)
}
}
// Falling edge
for k in centerBin..<endBin {
if endBin != centerBin {
filter[k] = Float(endBin - k) / Float(endBin - centerBin)
}
}
melFilterbank.append(filter)
}
}
private func calculateMelBands(from magnitudes: [Float]) -> [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..<count {
sum += magnitudes[j] * filter[j]
}
// Apply logarithmic scaling
result[i] = log10(1.0 + sum * 10.0) / log10(11.0)
}
// Apply smoothing to mel output
for i in 0..<melBandCount {
melOutput[i] = melOutput[i] * smoothingFactor + result[i] * (1.0 - smoothingFactor)
}
return melOutput
}
// MARK: - Sub-Bass Analysis
private func calculateSubBassEnergy(from magnitudes: [Float]) -> 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..<subBassBinCount {
sum += magnitudes[i] * magnitudes[i]
}
let rms = sqrt(sum / Float(subBassBinCount))
// Normalize and apply gain
let normalized = min(1.0, rms * 5.0 * (1.0 + reactivity))
return normalized
}
// MARK: - Sidechain Pump Detection
private func detectSidechainPump(subBassEnergy: Float) -> (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))
}
}