4c44d48a02
- 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
149 lines
5.5 KiB
C#
149 lines
5.5 KiB
C#
using System.Numerics;
|
|
using FftSharp;
|
|
|
|
namespace Substation;
|
|
|
|
public static class MusicAnalyzer
|
|
{
|
|
public const int WindowSize = 2048;
|
|
public const int HopSize = 1024;
|
|
public const double TickDuration = 0.1;
|
|
|
|
const double RhythmLow = 20, RhythmHigh = 250;
|
|
const double MelodyLow = 300, MelodyHigh = 4000;
|
|
|
|
public static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq)
|
|
ExtractFeatures(double[] magnitude, double[]? prevRhythmMag, int sampleRate)
|
|
{
|
|
double binWidth = (double)sampleRate / WindowSize;
|
|
int rhythmLoBin = (int)(RhythmLow / binWidth);
|
|
int rhythmHiBin = (int)(RhythmHigh / binWidth);
|
|
int melodyLoBin = (int)(MelodyLow / binWidth);
|
|
int melodyHiBin = (int)(MelodyHigh / binWidth);
|
|
|
|
// Rhythm band energy
|
|
double rhythmEnergy = 0;
|
|
for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++)
|
|
rhythmEnergy += magnitude[i] * magnitude[i];
|
|
rhythmEnergy = Math.Sqrt(rhythmEnergy / (rhythmHiBin - rhythmLoBin + 1));
|
|
|
|
// Spectral flux (positive change in rhythm band)
|
|
double rhythmFlux = 0;
|
|
if (prevRhythmMag != null)
|
|
{
|
|
for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++)
|
|
{
|
|
double diff = magnitude[i] - prevRhythmMag[i];
|
|
if (diff > 0) rhythmFlux += diff;
|
|
}
|
|
}
|
|
|
|
// Melody band: energy + dominant frequency (spectral peak)
|
|
double melodyEnergy = 0;
|
|
double peakMag = 0;
|
|
int peakBin = melodyLoBin;
|
|
for (int i = melodyLoBin; i <= melodyHiBin && i < magnitude.Length; i++)
|
|
{
|
|
double m = magnitude[i];
|
|
melodyEnergy += m * m;
|
|
if (m > peakMag)
|
|
{
|
|
peakMag = m;
|
|
peakBin = i;
|
|
}
|
|
}
|
|
melodyEnergy = Math.Sqrt(melodyEnergy / (melodyHiBin - melodyLoBin + 1));
|
|
double melodyFreq = peakBin * binWidth;
|
|
|
|
return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq);
|
|
}
|
|
|
|
public static int MapPitchToPeriod(double hz)
|
|
{
|
|
// Map melody frequency (300-4000Hz) to e-stim period (10-1000ms)
|
|
// Logarithmic mapping: low notes → deep, high notes → buzzy
|
|
double logFreq = Math.Log(Math.Clamp(hz, MelodyLow, MelodyHigh));
|
|
double logMin = Math.Log(MelodyLow);
|
|
double logMax = Math.Log(MelodyHigh);
|
|
double t = (logFreq - logMin) / (logMax - logMin); // 0..1
|
|
// Invert: high freq → short period (buzzy), low freq → long period (deep)
|
|
int ms = (int)Math.Round(1000 - t * 990); // 1000ms..10ms
|
|
return Math.Clamp(ms, 10, 1000);
|
|
}
|
|
|
|
public class TickFeature
|
|
{
|
|
public double RhythmFlux;
|
|
public double MelodyEnergy;
|
|
public double MelodyCount;
|
|
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;
|
|
|
|
double normalizedFlux = maxFlux > 0 ? tf.RhythmFlux / maxFlux : 0;
|
|
int onsetIntensity = (int)Math.Round(normalizedFlux * 100);
|
|
var intA = new[]
|
|
{
|
|
(byte)Math.Clamp(onsetIntensity, 0, 100),
|
|
(byte)Math.Clamp(onsetIntensity * 6 / 10, 0, 100),
|
|
(byte)Math.Clamp(onsetIntensity * 3 / 10, 0, 100),
|
|
(byte)0
|
|
};
|
|
var freqA = Freq.Compress4(new[] { rhythmFreqMs, rhythmFreqMs, rhythmFreqMs, rhythmFreqMs });
|
|
|
|
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(freqA, intA), new WaveFrame(freqB, intB));
|
|
}
|
|
}
|