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 _sampleBuffer = new(); readonly object _bufferLock = new(); double _liveMaxMelodyEnergy; int _sampleRate; int _fftsPerTick; int _fftIndexInTick; readonly DrumDetector _drums = new(); 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(); double[]? overlap = null; // last HopSize samples from previous window 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) { Thread.Sleep(5); continue; } window.ApplyInPlace(windowData); var spectrum = FFT.Forward(windowData); var mag = FFT.Magnitude(spectrum); 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 }); tf = new MusicAnalyzer.TickFeature(); _fftIndexInTick = 0; } } } public void Dispose() { _running = false; try { _capture?.Dispose(); } catch { } } }