2026-08-08 19:20:46 +00:00
using FftSharp ;
using NAudio.CoreAudioApi ;
using NAudio.Wave ;
2026-08-14 09:54:47 +00:00
using System.IO ;
2026-08-08 19:20:46 +00:00
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
2026-08-14 09:54:47 +00:00
int _dataAvailableCount ;
int _totalSamplesReceived ;
int _fftCount ;
int _tickCount ;
static readonly string LogPath = Path . Combine ( AppContext . BaseDirectory , "live.log" );
static readonly object LogLock = new ();
static void Log ( string msg )
{
var line = $"{DateTime.Now:HH:mm:ss.fff} {msg}" ;
lock ( LogLock )
{
try { File . AppendAllText ( LogPath , line + Environment . NewLine ); } catch { }
}
}
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 ));
2026-08-14 09:54:47 +00:00
try { File . WriteAllText ( LogPath , "" ); } catch { }
Log ( $"[live] device: {_device.FriendlyName}" );
Log ( $"[live] format: {_capture.WaveFormat} ({_capture.WaveFormat.BitsPerSample}bit, {_capture.WaveFormat.Channels}ch, {_sampleRate}Hz)" );
Log ( $"[live] fftsPerTick: {_fftsPerTick}" );
2026-08-08 19:20:46 +00:00
_capture . DataAvailable += OnDataAvailable ;
_capture . RecordingStopped += OnRecordingStopped ;
_processThread = new Thread ( ProcessLoop ) { IsBackground = true , Name = "LiveCapture-FFT" };
_processThread . Start ();
_capture . StartRecording ();
2026-08-14 09:54:47 +00:00
Log ( $"[live] capture started" );
Log ( $"[live] log file: {LogPath}" );
2026-08-08 19:20:46 +00:00
}
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 ;
2026-08-14 09:54:47 +00:00
_dataAvailableCount ++;
_totalSamplesReceived += sampleCount ;
if ( _dataAvailableCount % 100 == 1 )
Log ( $"[live] dataAvailable #{_dataAvailableCount}: {e.BytesRecorded} bytes, {sampleCount} samples, total={_totalSamplesReceived}, buffer={_sampleBuffer.Count}" );
2026-08-08 19:20:46 +00:00
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 ();
2026-08-14 09:54:47 +00:00
Log ( $"[live] capture stopped. dataAvailable={_dataAvailableCount}, totalSamples={_totalSamplesReceived}, ffts={_fftCount}, ticks={_tickCount}" );
if ( e ?. Exception != null )
Log ( $"[live] ERROR stop exception: {e.Exception.Message}" );
2026-08-08 19:20:46 +00:00
}
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
2026-08-14 09:54:47 +00:00
Log ( "[live] process thread started" );
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 )
{
2026-08-14 09:54:47 +00:00
if ( _fftCount == 0 && _dataAvailableCount > 0 && _dataAvailableCount % 200 == 0 )
Log ( $"[live] WARNING: data available ({_dataAvailableCount} callbacks, {_totalSamplesReceived} samples) but buffer has only {_sampleBuffer.Count} samples (need {MusicAnalyzer.WindowSize})" );
2026-08-08 19:20:46 +00:00
Thread . Sleep ( 5 );
continue ;
}
window . ApplyInPlace ( windowData );
var spectrum = FFT . Forward ( windowData );
var mag = FFT . Magnitude ( spectrum );
2026-08-14 09:54:47 +00:00
_fftCount ++;
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 });
2026-08-14 09:54:47 +00:00
_tickCount ++;
if ( _tickCount % 50 == 1 )
{
int intA = ( frameA . Intensity [ 0 ] + frameA . Intensity [ 1 ] + frameA . Intensity [ 2 ] + frameA . Intensity [ 3 ]) / 4 ;
int intB = ( frameB . Intensity [ 0 ] + frameB . Intensity [ 1 ] + frameB . Intensity [ 2 ] + frameB . Intensity [ 3 ]) / 4 ;
Log ( $"[live] tick #{_tickCount}: ffts={_fftCount}, melodyEnergy={melodyEnergy:F4}, maxMelody={_liveMaxMelodyEnergy:F4}, freq={melodyFreq:F0}Hz, intA={intA}, intB={intB}, freqA={frameA.Freq[0]}, freqB={frameB.Freq[0]}" );
}
2026-08-08 19:20:46 +00:00
tf = new MusicAnalyzer . TickFeature ();
_fftIndexInTick = 0 ;
}
}
}
public void Dispose ()
{
_running = false ;
try { _capture ?. Dispose (); } catch { }
}
}