using T3.Core.Utils; namespace T3.Editor.Gui.Interaction.Timing; public class BpmDetection { public float SampleDurationInSec { get; set; } = 25; public int BpmRangeMin { get; set; } = 80; public int BpmRangeMax { get; set; } = 180; public float NormalizedFrequencyRangeMin { get; set; } = 0; public float NormalizedFrequencyRangeMax { get; set; } = 0.2f; public float LockInFactor { get; set; } = 0.001f; public bool HasSufficientSampleData => _addedSampleCount >= SampleBufferSize; /// /// This should be called once a frame with a fresh set of fftSamples /// public void AddFftSample(float[] fftBuffer) { BpmRangeMin = BpmRangeMin.Clamp(50, 200); BpmRangeMax = BpmRangeMax.Clamp(BpmRangeMin, 200); UpdateSampleBuffer(fftBuffer); } /// /// Find the best matching BPM-rate. /// /// This call is very expensive (expect several milliseconds!) /// and should not be called every frame. public float ComputeBpmRate() { var bpmStepCount = BpmRangeMax - BpmRangeMin; if (_bpmEnergies.Count != bpmStepCount) _bpmEnergies = new List(new float[bpmStepCount]); SmoothBuffer(ref _smoothedSampleBuffer, _sampleBuffer); // Find best match in all relevant bpm rates var bestBpm = 0f; var bestMeasurement = float.PositiveInfinity; for (var bpm = BpmRangeMin; bpm < BpmRangeMax; bpm++) { var m = MeasureEnergyDifference(bpm) / ComputeFocusFactor(bpm, _currentBpm, 4, LockInFactor); if (m < bestMeasurement) { bestMeasurement = m; bestBpm = bpm; } _bpmEnergies[bpm - BpmRangeMin] = m; } foreach (var offset in _searchOffsets) { var bpm = _currentBpm + offset; if (bpm < 70 || bpm > 170) continue; var m = MeasureEnergyDifference(bpm) / ComputeFocusFactor(bpm, _currentBpm, 2, 0.01f); if (!(m < bestMeasurement)) continue; bestMeasurement = m; bestBpm = bpm; } _currentBpm = bestBpm; return bestBpm; } /// /// Create a fall-off curve /// /// https://www.desmos.com/calculator/xgculbggo1 private float ComputeFocusFactor(float value, float targetValue, float range = 3, float amplitude = 0.1f) { var deviance = Math.Abs(value - targetValue); var bump = Math.Max(0, 1 - (1f / (range * range) * deviance * deviance)) * amplitude + 1; return Math.Max(bump, 1); } public static float LastVolume; private void UpdateSampleBuffer(IReadOnlyList fftBuffer) { SampleDurationInSec = SampleDurationInSec.Clamp(1, 60); if (_sampleBuffer.Count != SampleBufferSize) _sampleBuffer = new List(new float[SampleBufferSize]); var lowerBorder = (int)(NormalizedFrequencyRangeMin.Clamp(0, 1) * FftResolution); var upperBorder = (int)(NormalizedFrequencyRangeMax.Clamp(0, 1) * FftResolution); if (upperBorder <= lowerBorder) return; _addedSampleCount++; var sum = 0f; for (var index = lowerBorder; index < upperBorder; index++) { sum += fftBuffer[index]; } //Log.Debug("Added fft sum " + sum); LastVolume = sum; _sampleBuffer.Add(sum); if (_sampleBuffer.Count > SampleBufferSize) _sampleBuffer.RemoveAt(0); } /// /// Subtracts the a smoothed version from the the sampleBuffer to cancel variations /// in volume / energy. /// /// This method is very slow (more like a proof-of-concept). It /// could be easily optimized by applying the smoothing only to the head sample buffer. private static void SmoothBuffer(ref float[] cleanedBuffer, IReadOnlyList sampleBuffer) { if (cleanedBuffer.Length != sampleBuffer.Count) cleanedBuffer = new float[sampleBuffer.Count]; const int smoothSteps = 5; if (sampleBuffer.Count < smoothSteps * 2 + 1) return; for (var i = smoothSteps; i < sampleBuffer.Count - smoothSteps; i++) { var sum = 0f; for (var j = -smoothSteps; j < smoothSteps; j++) { sum += sampleBuffer[i + j]; } cleanedBuffer[i] = Math.Max(0, sampleBuffer[i] - sum / (smoothSteps * 2 + 1)); } } /// /// The actual "algorithm" measure the difference between timing offsets in the sample buffer. /// It shifts towards the presence and skips comparision of older parts for which not all /// copies can have data. /// /// This is slow as f***k and should be easy to optimize. private float MeasureEnergyDifference(float bpm) { var dt = (240f / bpm * 60 / 4); var sum = 0.1f; const int slideScans = 4; const int clipStart = (int)(240f / 80 * 60 / 4) * slideScans + 1; for (var j = 1; j < slideScans; j++) { var offset = (int)(dt * j); var startIndex = Math.Max(clipStart, offset); if (startIndex >= _smoothedSampleBuffer.Length) continue; for (var i = startIndex; i < _smoothedSampleBuffer.Length; i++) { sum += Math.Abs(_smoothedSampleBuffer[i] - _smoothedSampleBuffer[i - offset]); } } return sum; } private int _addedSampleCount; private const int FramesPerSecond = 60; private const int FftResolution = 512; private float _currentBpm = 66; private readonly float[] _searchOffsets = { -0.3f, -0.1f, 0, 0.1f, 0.3f, }; private int SampleBufferSize => (int)SampleDurationInSec.Clamp(1, 60) * FramesPerSecond; private List _sampleBuffer = new(); private float[] _smoothedSampleBuffer = Array.Empty(); private List _bpmEnergies = new(128); }