Add drum detection for channel A in live capture

- DrumDetector.cs: 3-band onset detection (kick/snare/brass) with adaptive
  thresholds, per-sub-tick timing within 100ms frames
- Kick: low-band onset, 150ms deep thump
- Snare: broadband (mid+high) onset, 50ms mid punch
- Brass: high-only onset, 10ms buzzy
- Priority: kick > snare > brass when multiple hit same sub-tick
- Intensity = max (100) on hit, 0 on silence — user limiter scales down
- MusicAnalyzer: add BuildMelodyFrame for chB only (pitch+energy)
- LiveCapture: chA now from DrumDetector, chB unchanged (melody)
- Sub-tick mapping: FFT window position → 0-3 within each 100ms tick
This commit is contained in:
2026-08-09 11:32:16 +00:00
parent abb5364bb4
commit 4c44d48a02
3 changed files with 182 additions and 2 deletions
+146
View File
@@ -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<double>();
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;
}
}
+9 -2
View File
@@ -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 });
+27
View File
@@ -79,6 +79,33 @@ public static class MusicAnalyzer
public readonly List<double> 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;