Add live audio capture via WASAPI loopback

- LiveCapture.cs: real-time WASAPI loopback capture with rolling buffer
  and continuous FFT analysis, enqueues WaveFrames at ~10Hz
- MusicAnalyzer.cs: refactored ExtractFeatures/MapPitchToPeriod/BuildWaveFrame
  as public reusable methods with sampleRate parameter
- MainForm: audio device selector (defaults to system default), Live button
  starts/stops capture, Stop All stops live too
- Adaptive normalization (running max with slow decay) for live volume changes
This commit is contained in:
2026-08-08 19:20:46 +00:00
parent 933c7ede38
commit ccc5093ee2
3 changed files with 330 additions and 84 deletions
+60 -58
View File
@@ -8,27 +8,26 @@ public record MusicPattern(List<WaveFrame> ChannelA, List<WaveFrame> ChannelB, T
public static class MusicAnalyzer
{
const int SampleRate = 44100;
const int WindowSize = 2048;
const int HopSize = 1024;
const double TickDuration = 0.1; // 100ms per e-stim frame
public const int WindowSize = 2048;
public const int HopSize = 1024;
public const double TickDuration = 0.1;
// Frequency band boundaries (Hz)
const double RhythmLow = 20, RhythmHigh = 250;
const double MelodyLow = 300, MelodyHigh = 4000;
public static MusicPattern Analyze(string mp3Path, IProgress<int>? progress = null)
{
var samples = DecodeToMono(mp3Path);
var duration = TimeSpan.FromSeconds((double)samples.Length / SampleRate);
const int sampleRate = 44100;
var samples = DecodeToMono(mp3Path, sampleRate);
var duration = TimeSpan.FromSeconds((double)samples.Length / sampleRate);
var tickCount = (int)Math.Ceiling((double)samples.Length / SampleRate / TickDuration);
var tickCount = (int)Math.Ceiling((double)samples.Length / sampleRate / TickDuration);
var channelA = new List<WaveFrame>(tickCount);
var channelB = new List<WaveFrame>(tickCount);
var window = new FftSharp.Windows.Hanning();
var buffer = new double[WindowSize];
int fftsPerTick = (int)Math.Round(TickDuration * SampleRate / HopSize);
int fftsPerTick = (int)Math.Round(TickDuration * sampleRate / HopSize);
double[]? prevRhythmMag = null;
double maxFlux = 0;
@@ -49,7 +48,7 @@ public static class MusicAnalyzer
var mag = FFT.Magnitude(spectrum);
var (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) =
ExtractFeatures(mag, prevRhythmMag);
ExtractFeatures(mag, prevRhythmMag, sampleRate);
if (rhythmFlux > maxFlux) maxFlux = rhythmFlux;
if (melodyEnergy > maxMelodyEnergy) maxMelodyEnergy = melodyEnergy;
@@ -79,57 +78,20 @@ public static class MusicAnalyzer
progress?.Report(100);
// Second pass: normalize and build WaveFrames
const int rhythmFreqMs = 150; // ~7Hz deep pulse
foreach (var tf in tickFeatures)
{
// Channel A: rhythm onset
double normalizedFlux = maxFlux > 0 ? tf.RhythmFlux / maxFlux : 0;
int onsetIntensity = (int)Math.Round(normalizedFlux * 100);
// Sub-tick attack/decay shape
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 });
channelA.Add(new WaveFrame(freqA, intA));
// Channel B: melody
double avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0;
double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80); // cap at 80 for comfort
melodyIntensity = Math.Clamp(melodyIntensity, 0, 100);
// Weighted average dominant frequency
double weightedFreq = 0;
double totalWeight = 0;
foreach (var f in tf.MelodyFreqSamples)
{
weightedFreq += f * f; // weight by energy (freq already squared mag)
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 });
channelB.Add(new WaveFrame(freqB, intB));
var (frameA, frameB) = BuildWaveFrame(tf, maxFlux, maxMelodyEnergy);
channelA.Add(frameA);
channelB.Add(frameB);
}
return new MusicPattern(channelA, channelB, duration);
}
static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq) ExtractFeatures(
double[] magnitude, double[]? prevRhythmMag)
public static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq)
ExtractFeatures(double[] magnitude, double[]? prevRhythmMag, int sampleRate)
{
double binWidth = (double)SampleRate / WindowSize;
double binWidth = (double)sampleRate / WindowSize;
int rhythmLoBin = (int)(RhythmLow / binWidth);
int rhythmHiBin = (int)(RhythmHigh / binWidth);
int melodyLoBin = (int)(MelodyLow / binWidth);
@@ -172,7 +134,7 @@ public static class MusicAnalyzer
return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq);
}
static int MapPitchToPeriod(double hz)
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
@@ -185,15 +147,15 @@ public static class MusicAnalyzer
return Math.Clamp(ms, 10, 1000);
}
static double[] DecodeToMono(string mp3Path)
static double[] DecodeToMono(string mp3Path, int sampleRate)
{
using var reader = new Mp3FileReader(mp3Path);
var format = new WaveFormat(SampleRate, 16, 1);
var format = new WaveFormat(sampleRate, 16, 1);
using var resampler = new MediaFoundationResampler(reader, format);
resampler.ResamplerQuality = 60;
var sampleList = new List<float>();
var buffer = new byte[SampleRate * 2]; // 1s worth of 16-bit mono
var buffer = new byte[sampleRate * 2]; // 1s worth of 16-bit mono
int read;
while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0)
{
@@ -210,11 +172,51 @@ public static class MusicAnalyzer
return result;
}
class TickFeature
public class TickFeature
{
public double RhythmFlux;
public double MelodyEnergy;
public double MelodyCount;
public readonly List<double> MelodyFreqSamples = new();
}
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));
}
}