diff --git a/DrumDetector.cs b/DrumDetector.cs new file mode 100644 index 0000000..0ce4870 --- /dev/null +++ b/DrumDetector.cs @@ -0,0 +1,146 @@ +namespace Substation; + +public class DrumDetector +{ + const int HistorySize = 50; + + readonly double[] _lowFluxHistory = new double[HistorySize]; + readonly double[] _midFluxHistory = new double[HistorySize]; + readonly double[] _highFluxHistory = new double[HistorySize]; + int _historyIndex; + + double[]? _prevLowMag; + double[]? _prevMidMag; + double[]? _prevHighMag; + + readonly bool[] _subKick = new bool[4]; + readonly bool[] _subSnare = new bool[4]; + readonly bool[] _subBrass = new bool[4]; + + static readonly byte[] FreqKick = Freq.Compress4(new[] { 150, 150, 150, 150 }); + static readonly byte[] FreqSnare = Freq.Compress4(new[] { 50, 50, 50, 50 }); + static readonly byte[] FreqBrass = Freq.Compress4(new[] { 10, 10, 10, 10 }); + static readonly byte[] FreqSilent = Freq.Compress4(new[] { 10, 10, 10, 10 }); + + public void ProcessWindow(double[] magnitude, int sampleRate, int windowSize, int subTickIndex) + { + if (subTickIndex < 0 || subTickIndex > 3) return; + + double binWidth = (double)sampleRate / windowSize; + int lowLo = (int)(20 / binWidth), lowHi = (int)(150 / binWidth); + int midLo = (int)(200 / binWidth), midHi = (int)(500 / binWidth); + int highLo = (int)(5000 / binWidth), highHi = (int)(15000 / binWidth); + + var lowMag = ExtractBand(magnitude, lowLo, lowHi); + var midMag = ExtractBand(magnitude, midLo, midHi); + var highMag = ExtractBand(magnitude, highLo, highHi); + + double lowFlux = ComputeFlux(lowMag, _prevLowMag); + double midFlux = ComputeFlux(midMag, _prevMidMag); + double highFlux = ComputeFlux(highMag, _prevHighMag); + + _prevLowMag = lowMag; + _prevMidMag = midMag; + _prevHighMag = highMag; + + // Update history and compute adaptive thresholds + _lowFluxHistory[_historyIndex] = lowFlux; + _midFluxHistory[_historyIndex] = midFlux; + _highFluxHistory[_historyIndex] = highFlux; + _historyIndex = (_historyIndex + 1) % HistorySize; + + double lowThresh = AdaptiveThreshold(_lowFluxHistory); + double midThresh = AdaptiveThreshold(_midFluxHistory); + double highThresh = AdaptiveThreshold(_highFluxHistory); + + // Detect onsets + bool kickOnset = lowFlux > lowThresh && lowFlux > 0.001; + bool snareOnset = midFlux > midThresh && highFlux > highThresh && midFlux > 0.001; + bool brassOnset = highFlux > highThresh && !snareOnset && highFlux > 0.001; + + if (kickOnset) _subKick[subTickIndex] = true; + if (snareOnset) _subSnare[subTickIndex] = true; + if (brassOnset) _subBrass[subTickIndex] = true; + } + + public WaveFrame BuildFrame() + { + var intensity = new byte[4]; + var freqBytes = new byte[16]; // 4 sub-ticks × 4 bytes (but we use per-sub-tick freq) + + for (int i = 0; i < 4; i++) + { + byte freq; + if (_subKick[i]) + { + intensity[i] = 100; + freq = Freq.Compress(150); + } + else if (_subSnare[i]) + { + intensity[i] = 100; + freq = Freq.Compress(50); + } + else if (_subBrass[i]) + { + intensity[i] = 100; + freq = Freq.Compress(10); + } + else + { + intensity[i] = 0; + freq = 10; + } + + freqBytes[i] = freq; + } + + // Reset for next tick + Array.Clear(_subKick, 0, 4); + Array.Clear(_subSnare, 0, 4); + Array.Clear(_subBrass, 0, 4); + + return new WaveFrame(freqBytes, intensity); + } + + static double[] ExtractBand(double[] magnitude, int loBin, int hiBin) + { + if (hiBin <= loBin || hiBin >= magnitude.Length) + return Array.Empty(); + + var band = new double[hiBin - loBin + 1]; + Array.Copy(magnitude, loBin, band, 0, band.Length); + return band; + } + + static double ComputeFlux(double[] current, double[]? previous) + { + if (previous == null || current.Length != previous.Length) return 0; + + double flux = 0; + for (int i = 0; i < current.Length; i++) + { + double diff = current[i] - previous[i]; + if (diff > 0) flux += diff; + } + return flux; + } + + static double AdaptiveThreshold(double[] history) + { + double sum = 0, sumSq = 0; + int count = 0; + foreach (var v in history) + { + if (v <= 0) continue; + sum += v; + sumSq += v * v; + count++; + } + if (count == 0) return double.MaxValue; + double mean = sum / count; + double variance = sumSq / count - mean * mean; + double stddev = variance > 0 ? Math.Sqrt(variance) : 0; + return mean + 2 * stddev; + } +} diff --git a/LiveCapture.cs b/LiveCapture.cs index 27c90ec..c64f5fb 100644 --- a/LiveCapture.cs +++ b/LiveCapture.cs @@ -22,6 +22,7 @@ public class LiveCapture : IDisposable int _sampleRate; int _fftsPerTick; int _fftIndexInTick; + readonly DrumDetector _drums = new(); public event Action? Stopped; @@ -139,7 +140,12 @@ public class LiveCapture : IDisposable _liveMaxFlux = Math.Max(rhythmFlux, _liveMaxFlux * 0.999); _liveMaxMelodyEnergy = Math.Max(melodyEnergy, _liveMaxMelodyEnergy * 0.999); - tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux); + // Drum detection: map this FFT window to a sub-tick (0-3) + int subTick = _fftsPerTick > 0 ? _fftIndexInTick * 4 / _fftsPerTick : 0; + if (subTick > 3) subTick = 3; + _drums.ProcessWindow(mag, _sampleRate, MusicAnalyzer.WindowSize, subTick); + + // Melody accumulation for chB tf.MelodyEnergy += melodyEnergy; tf.MelodyFreqSamples.Add(melodyFreq); tf.MelodyCount++; @@ -147,7 +153,8 @@ public class LiveCapture : IDisposable if (_fftIndexInTick >= _fftsPerTick) { - var (frameA, frameB) = MusicAnalyzer.BuildWaveFrame(tf, _liveMaxFlux, _liveMaxMelodyEnergy); + var frameA = _drums.BuildFrame(); + var frameB = MusicAnalyzer.BuildMelodyFrame(tf, _liveMaxMelodyEnergy); _state.EnqueueStream('A', new[] { frameA }); _state.EnqueueStream('B', new[] { frameB }); diff --git a/MusicAnalyzer.cs b/MusicAnalyzer.cs index 9588f1f..586811e 100644 --- a/MusicAnalyzer.cs +++ b/MusicAnalyzer.cs @@ -79,6 +79,33 @@ public static class MusicAnalyzer public readonly List MelodyFreqSamples = new(); } + public static WaveFrame BuildMelodyFrame(TickFeature tf, double maxMelodyEnergy) + { + double avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0; + double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0; + int melodyIntensity = (int)Math.Round(normalizedEnergy * 80); + melodyIntensity = Math.Clamp(melodyIntensity, 0, 100); + + double weightedFreq = 0; + double totalWeight = 0; + foreach (var f in tf.MelodyFreqSamples) + { + weightedFreq += f * f; + totalWeight += f; + } + double avgMelodyHz = totalWeight > 0 ? weightedFreq / totalWeight : 500; + int estimsMs = MapPitchToPeriod(avgMelodyHz); + + var intB = new[] + { + (byte)melodyIntensity, (byte)melodyIntensity, + (byte)melodyIntensity, (byte)melodyIntensity + }; + var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs }); + + return new WaveFrame(freqB, intB); + } + public static (WaveFrame chA, WaveFrame chB) BuildWaveFrame(TickFeature tf, double maxFlux, double maxMelodyEnergy) { const int rhythmFreqMs = 150;