2026-08-08 19:20:46 +00:00
|
|
|
using FftSharp;
|
|
|
|
|
using NAudio.CoreAudioApi;
|
|
|
|
|
using NAudio.Wave;
|
|
|
|
|
|
|
|
|
|
namespace Substation;
|
|
|
|
|
|
|
|
|
|
public class LiveCapture : IDisposable
|
|
|
|
|
{
|
|
|
|
|
readonly State _state;
|
|
|
|
|
readonly MMDevice _device;
|
|
|
|
|
WasapiLoopbackCapture? _capture;
|
|
|
|
|
Thread? _processThread;
|
|
|
|
|
volatile bool _running;
|
|
|
|
|
|
|
|
|
|
readonly Queue<double> _sampleBuffer = new();
|
|
|
|
|
readonly object _bufferLock = new();
|
|
|
|
|
|
|
|
|
|
double _liveMaxMelodyEnergy;
|
|
|
|
|
int _sampleRate;
|
|
|
|
|
int _fftsPerTick;
|
|
|
|
|
int _fftIndexInTick;
|
2026-08-09 11:32:16 +00:00
|
|
|
readonly DrumDetector _drums = new();
|
2026-08-08 19:20:46 +00:00
|
|
|
|
|
|
|
|
public event Action? Stopped;
|
|
|
|
|
|
|
|
|
|
public LiveCapture(State state, MMDevice device)
|
|
|
|
|
{
|
|
|
|
|
_state = state;
|
|
|
|
|
_device = device;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Start()
|
|
|
|
|
{
|
|
|
|
|
_running = true;
|
|
|
|
|
_capture = new WasapiLoopbackCapture(_device);
|
|
|
|
|
_sampleRate = _capture.WaveFormat.SampleRate;
|
|
|
|
|
_fftsPerTick = Math.Max(1, (int)Math.Round(MusicAnalyzer.TickDuration * _sampleRate / MusicAnalyzer.HopSize));
|
|
|
|
|
|
|
|
|
|
_capture.DataAvailable += OnDataAvailable;
|
|
|
|
|
_capture.RecordingStopped += OnRecordingStopped;
|
|
|
|
|
|
|
|
|
|
_processThread = new Thread(ProcessLoop) { IsBackground = true, Name = "LiveCapture-FFT" };
|
|
|
|
|
_processThread.Start();
|
|
|
|
|
|
|
|
|
|
_capture.StartRecording();
|
|
|
|
|
Console.WriteLine($"[live] capture started: {_device.FriendlyName} ({_sampleRate}Hz, {_capture.WaveFormat.Channels}ch)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Stop()
|
|
|
|
|
{
|
|
|
|
|
_running = false;
|
|
|
|
|
try { _capture?.StopRecording(); } catch { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnDataAvailable(object? sender, WaveInEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
int channels = _capture!.WaveFormat.Channels;
|
|
|
|
|
int bytesPerSample = _capture.WaveFormat.BitsPerSample / 8;
|
|
|
|
|
int frameSize = channels * bytesPerSample;
|
|
|
|
|
int sampleCount = e.BytesRecorded / frameSize;
|
|
|
|
|
|
|
|
|
|
lock (_bufferLock)
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < sampleCount; i++)
|
|
|
|
|
{
|
|
|
|
|
int offset = i * frameSize;
|
|
|
|
|
float left = BitConverter.ToSingle(e.Buffer, offset);
|
|
|
|
|
float right = channels >= 2
|
|
|
|
|
? BitConverter.ToSingle(e.Buffer, offset + bytesPerSample)
|
|
|
|
|
: left;
|
|
|
|
|
_sampleBuffer.Enqueue((left + right) * 0.5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cap buffer size to prevent memory growth if processing falls behind
|
|
|
|
|
while (_sampleBuffer.Count > _sampleRate * 2)
|
|
|
|
|
_sampleBuffer.Dequeue();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_running = false;
|
|
|
|
|
Stopped?.Invoke();
|
|
|
|
|
Console.WriteLine("[live] capture stopped");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ProcessLoop()
|
|
|
|
|
{
|
|
|
|
|
var window = new FftSharp.Windows.Hanning();
|
|
|
|
|
var buffer = new double[MusicAnalyzer.WindowSize];
|
|
|
|
|
var tf = new MusicAnalyzer.TickFeature();
|
2026-08-08 19:52:56 +00:00
|
|
|
double[]? overlap = null; // last HopSize samples from previous window
|
2026-08-08 19:20:46 +00:00
|
|
|
|
|
|
|
|
while (_running)
|
|
|
|
|
{
|
|
|
|
|
double[]? windowData = null;
|
|
|
|
|
|
|
|
|
|
lock (_bufferLock)
|
|
|
|
|
{
|
2026-08-08 19:52:56 +00:00
|
|
|
int needed = overlap != null ? MusicAnalyzer.HopSize : MusicAnalyzer.WindowSize;
|
|
|
|
|
if (_sampleBuffer.Count >= needed)
|
2026-08-08 19:20:46 +00:00
|
|
|
{
|
2026-08-08 19:52:56 +00:00
|
|
|
if (overlap != null)
|
|
|
|
|
{
|
|
|
|
|
Array.Copy(overlap, 0, buffer, 0, MusicAnalyzer.HopSize);
|
|
|
|
|
for (int i = 0; i < MusicAnalyzer.HopSize; i++)
|
|
|
|
|
buffer[MusicAnalyzer.HopSize + i] = _sampleBuffer.Dequeue();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < MusicAnalyzer.WindowSize; i++)
|
|
|
|
|
buffer[i] = _sampleBuffer.Dequeue();
|
|
|
|
|
}
|
|
|
|
|
// Save last HopSize samples for next window's overlap
|
|
|
|
|
overlap = new double[MusicAnalyzer.HopSize];
|
|
|
|
|
Array.Copy(buffer, MusicAnalyzer.HopSize, overlap, 0, MusicAnalyzer.HopSize);
|
2026-08-08 19:20:46 +00:00
|
|
|
windowData = buffer;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (windowData == null)
|
|
|
|
|
{
|
|
|
|
|
Thread.Sleep(5);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.ApplyInPlace(windowData);
|
|
|
|
|
var spectrum = FFT.Forward(windowData);
|
|
|
|
|
var mag = FFT.Magnitude(spectrum);
|
|
|
|
|
|
2026-08-09 14:01:19 +00:00
|
|
|
var (melodyEnergy, melodyFreq) = MusicAnalyzer.ExtractFeatures(mag, _sampleRate);
|
2026-08-08 19:20:46 +00:00
|
|
|
|
|
|
|
|
// Adaptive normalization: running max with slow decay
|
|
|
|
|
_liveMaxMelodyEnergy = Math.Max(melodyEnergy, _liveMaxMelodyEnergy * 0.999);
|
|
|
|
|
|
2026-08-09 11:32:16 +00:00
|
|
|
// 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
|
2026-08-08 19:20:46 +00:00
|
|
|
tf.MelodyEnergy += melodyEnergy;
|
|
|
|
|
tf.MelodyFreqSamples.Add(melodyFreq);
|
|
|
|
|
tf.MelodyCount++;
|
|
|
|
|
_fftIndexInTick++;
|
|
|
|
|
|
|
|
|
|
if (_fftIndexInTick >= _fftsPerTick)
|
|
|
|
|
{
|
2026-08-09 11:32:16 +00:00
|
|
|
var frameA = _drums.BuildFrame();
|
|
|
|
|
var frameB = MusicAnalyzer.BuildMelodyFrame(tf, _liveMaxMelodyEnergy);
|
2026-08-08 19:20:46 +00:00
|
|
|
_state.EnqueueStream('A', new[] { frameA });
|
|
|
|
|
_state.EnqueueStream('B', new[] { frameB });
|
|
|
|
|
|
|
|
|
|
tf = new MusicAnalyzer.TickFeature();
|
|
|
|
|
_fftIndexInTick = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
_running = false;
|
|
|
|
|
try { _capture?.Dispose(); } catch { }
|
|
|
|
|
}
|
|
|
|
|
}
|