using FftSharp; using NAudio.CoreAudioApi; using NAudio.Wave; using System.IO; namespace Substation; public class LiveCapture : IDisposable { readonly State _state; readonly MMDevice _device; WasapiLoopbackCapture? _capture; Thread? _processThread; volatile bool _running; readonly Queue _sampleBuffer = new(); readonly object _bufferLock = new(); double _liveMaxMelodyEnergy; int _sampleRate; int _fftsPerTick; int _fftIndexInTick; readonly DrumDetector _drums = new(); 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 { } } } 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)); 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}"); _capture.DataAvailable += OnDataAvailable; _capture.RecordingStopped += OnRecordingStopped; _processThread = new Thread(ProcessLoop) { IsBackground = true, Name = "LiveCapture-FFT" }; _processThread.Start(); _capture.StartRecording(); Log($"[live] capture started"); Log($"[live] log file: {LogPath}"); } 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; _dataAvailableCount++; _totalSamplesReceived += sampleCount; if (_dataAvailableCount % 100 == 1) Log($"[live] dataAvailable #{_dataAvailableCount}: {e.BytesRecorded} bytes, {sampleCount} samples, total={_totalSamplesReceived}, buffer={_sampleBuffer.Count}"); 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(); Log($"[live] capture stopped. dataAvailable={_dataAvailableCount}, totalSamples={_totalSamplesReceived}, ffts={_fftCount}, ticks={_tickCount}"); if (e?.Exception != null) Log($"[live] ERROR stop exception: {e.Exception.Message}"); } void ProcessLoop() { var window = new FftSharp.Windows.Hanning(); var buffer = new double[MusicAnalyzer.WindowSize]; var tf = new MusicAnalyzer.TickFeature(); double[]? overlap = null; // last HopSize samples from previous window Log("[live] process thread started"); while (_running) { double[]? windowData = null; lock (_bufferLock) { int needed = overlap != null ? MusicAnalyzer.HopSize : MusicAnalyzer.WindowSize; if (_sampleBuffer.Count >= needed) { 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); windowData = buffer; } } if (windowData == null) { 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})"); Thread.Sleep(5); continue; } window.ApplyInPlace(windowData); var spectrum = FFT.Forward(windowData); var mag = FFT.Magnitude(spectrum); _fftCount++; var (melodyEnergy, melodyFreq) = MusicAnalyzer.ExtractFeatures(mag, _sampleRate); // Adaptive normalization: running max with slow decay _liveMaxMelodyEnergy = Math.Max(melodyEnergy, _liveMaxMelodyEnergy * 0.999); // 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++; _fftIndexInTick++; if (_fftIndexInTick >= _fftsPerTick) { var frameA = _drums.BuildFrame(); var frameB = MusicAnalyzer.BuildMelodyFrame(tf, _liveMaxMelodyEnergy); _state.EnqueueStream('A', new[] { frameA }); _state.EnqueueStream('B', new[] { frameB }); _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]}"); } tf = new MusicAnalyzer.TickFeature(); _fftIndexInTick = 0; } } } public void Dispose() { _running = false; try { _capture?.Dispose(); } catch { } } }