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:
+119
-26
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Diagnostics;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace Substation;
|
||||
@@ -48,10 +49,15 @@ public class MainForm : Form
|
||||
string _trayState = "";
|
||||
|
||||
readonly Button _btnMusic;
|
||||
readonly Button _btnLive;
|
||||
readonly Label _lblTrack;
|
||||
readonly Label _lblAudioDev;
|
||||
readonly ComboBox _cboAudioDev;
|
||||
bool _isMusicRunning;
|
||||
bool _isLiveRunning;
|
||||
WaveOutEvent? _audioOut;
|
||||
Stopwatch? _musicStopwatch;
|
||||
LiveCapture? _liveCapture;
|
||||
|
||||
public MainForm(CoyoteDevice device, State state, Server server)
|
||||
{
|
||||
@@ -61,7 +67,7 @@ public class MainForm : Form
|
||||
_loopCts = new CancellationTokenSource();
|
||||
|
||||
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
|
||||
ClientSize = new Size(420, 425);
|
||||
ClientSize = new Size(420, 450);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
@@ -117,30 +123,52 @@ public class MainForm : Form
|
||||
{
|
||||
Text = "Test",
|
||||
Location = new Point(16, 96),
|
||||
Size = new Size(100, 32)
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnTest.Click += OnTest;
|
||||
|
||||
_btnMusic = new Button
|
||||
{
|
||||
Text = "Music",
|
||||
Location = new Point(130, 96),
|
||||
Size = new Size(100, 32)
|
||||
Location = new Point(104, 96),
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnMusic.Click += OnMusic;
|
||||
|
||||
_btnLive = new Button
|
||||
{
|
||||
Text = "Live",
|
||||
Location = new Point(192, 96),
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnLive.Click += OnLive;
|
||||
|
||||
_btnStop = new Button
|
||||
{
|
||||
Text = "Stop All",
|
||||
Location = new Point(244, 96),
|
||||
Size = new Size(100, 32)
|
||||
Location = new Point(280, 96),
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnStop.Click += OnStop;
|
||||
|
||||
_lblAudioDev = new Label
|
||||
{
|
||||
Text = "Audio:",
|
||||
Location = new Point(16, 134),
|
||||
Size = new Size(40, 20)
|
||||
};
|
||||
_cboAudioDev = new ComboBox
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Location = new Point(60, 132),
|
||||
Size = new Size(344, 22)
|
||||
};
|
||||
PopulateAudioDevices();
|
||||
|
||||
_lblTrack = new Label
|
||||
{
|
||||
Text = "",
|
||||
Location = new Point(16, 134),
|
||||
Location = new Point(16, 158),
|
||||
Size = new Size(388, 16),
|
||||
ForeColor = Color.DimGray
|
||||
};
|
||||
@@ -148,37 +176,37 @@ public class MainForm : Form
|
||||
_lblSendA = new Label
|
||||
{
|
||||
Text = "Send A",
|
||||
Location = new Point(16, 160),
|
||||
Location = new Point(16, 184),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_heatA = new HeatMap(60)
|
||||
{
|
||||
Location = new Point(72, 158),
|
||||
Location = new Point(72, 182),
|
||||
Size = new Size(332, 22)
|
||||
};
|
||||
_lblSendB = new Label
|
||||
{
|
||||
Text = "Send B",
|
||||
Location = new Point(16, 188),
|
||||
Location = new Point(16, 212),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_heatB = new HeatMap(60)
|
||||
{
|
||||
Location = new Point(72, 186),
|
||||
Location = new Point(72, 210),
|
||||
Size = new Size(332, 22)
|
||||
};
|
||||
_lblRecvA = new Label
|
||||
{
|
||||
Text = "Recv A",
|
||||
Location = new Point(16, 216),
|
||||
Location = new Point(16, 240),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_gaugeRecvA = new ProgressBar
|
||||
{
|
||||
Location = new Point(72, 216),
|
||||
Location = new Point(72, 240),
|
||||
Size = new Size(332, 20),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
@@ -187,13 +215,13 @@ public class MainForm : Form
|
||||
_lblRecvB = new Label
|
||||
{
|
||||
Text = "Recv B",
|
||||
Location = new Point(16, 242),
|
||||
Location = new Point(16, 266),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_gaugeRecvB = new ProgressBar
|
||||
{
|
||||
Location = new Point(72, 242),
|
||||
Location = new Point(72, 266),
|
||||
Size = new Size(332, 20),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
@@ -203,13 +231,13 @@ public class MainForm : Form
|
||||
_lblLimitA = new Label
|
||||
{
|
||||
Text = "Lim A",
|
||||
Location = new Point(16, 274),
|
||||
Location = new Point(16, 298),
|
||||
Size = new Size(40, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_limitBarA = new TrackBar
|
||||
{
|
||||
Location = new Point(60, 270),
|
||||
Location = new Point(60, 294),
|
||||
Size = new Size(220, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
@@ -221,14 +249,14 @@ public class MainForm : Form
|
||||
_lblLimitValA = new Label
|
||||
{
|
||||
Text = "30",
|
||||
Location = new Point(286, 274),
|
||||
Location = new Point(286, 298),
|
||||
Size = new Size(32, 20),
|
||||
TextAlign = ContentAlignment.MiddleRight
|
||||
};
|
||||
_chkScaleA = new CheckBox
|
||||
{
|
||||
Text = "Scale",
|
||||
Location = new Point(320, 274),
|
||||
Location = new Point(320, 298),
|
||||
Size = new Size(76, 24),
|
||||
Checked = false
|
||||
};
|
||||
@@ -237,13 +265,13 @@ public class MainForm : Form
|
||||
_lblLimitB = new Label
|
||||
{
|
||||
Text = "Lim B",
|
||||
Location = new Point(16, 322),
|
||||
Location = new Point(16, 346),
|
||||
Size = new Size(40, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_limitBarB = new TrackBar
|
||||
{
|
||||
Location = new Point(60, 318),
|
||||
Location = new Point(60, 342),
|
||||
Size = new Size(220, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
@@ -255,21 +283,21 @@ public class MainForm : Form
|
||||
_lblLimitValB = new Label
|
||||
{
|
||||
Text = "30",
|
||||
Location = new Point(286, 322),
|
||||
Location = new Point(286, 346),
|
||||
Size = new Size(32, 20),
|
||||
TextAlign = ContentAlignment.MiddleRight
|
||||
};
|
||||
_chkScaleB = new CheckBox
|
||||
{
|
||||
Text = "Scale",
|
||||
Location = new Point(320, 322),
|
||||
Location = new Point(320, 346),
|
||||
Size = new Size(76, 24),
|
||||
Checked = false
|
||||
};
|
||||
_chkScaleB.CheckedChanged += OnLimitChanged;
|
||||
OnLimitChanged(null, EventArgs.Empty);
|
||||
|
||||
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
||||
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnMusic, _btnLive, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
||||
|
||||
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
||||
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
||||
@@ -437,8 +465,9 @@ public class MainForm : Form
|
||||
|
||||
void RefreshButtonStates()
|
||||
{
|
||||
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
|
||||
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning;
|
||||
_btnTest.Enabled = !_server.HasClient && !IsTestRunning && !_isMusicRunning && !_isLiveRunning;
|
||||
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning && !IsTestRunning && !_isLiveRunning;
|
||||
_btnLive.Enabled = !_server.HasClient && !_isLiveRunning && !IsTestRunning && !_isMusicRunning;
|
||||
_btnStop.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -451,6 +480,10 @@ public class MainForm : Form
|
||||
var elapsed = _musicStopwatch.Elapsed;
|
||||
_lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}";
|
||||
}
|
||||
else if (_isLiveRunning)
|
||||
{
|
||||
_lblTrack.Text = "Live capture";
|
||||
}
|
||||
|
||||
UpdateTrayIcon();
|
||||
}
|
||||
@@ -516,6 +549,7 @@ public class MainForm : Form
|
||||
void OnStop(object? sender, EventArgs e)
|
||||
{
|
||||
StopMusic();
|
||||
StopLive();
|
||||
_state.SetStrength('A', 0);
|
||||
_state.SetStrength('B', 0);
|
||||
_state.Stop('A');
|
||||
@@ -639,6 +673,65 @@ public class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
void PopulateAudioDevices()
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
var defaultDev = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console);
|
||||
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active);
|
||||
|
||||
_cboAudioDev.Items.Clear();
|
||||
int defaultIdx = 0;
|
||||
for (int i = 0; i < devices.Count; i++)
|
||||
{
|
||||
_cboAudioDev.Items.Add(devices[i]);
|
||||
if (devices[i].ID == defaultDev.ID)
|
||||
defaultIdx = i;
|
||||
}
|
||||
_cboAudioDev.SelectedIndex = defaultIdx;
|
||||
}
|
||||
|
||||
void OnLive(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isLiveRunning || _server.HasClient) return;
|
||||
if (_cboAudioDev.SelectedItem is not MMDevice dev) return;
|
||||
|
||||
_isLiveRunning = true;
|
||||
_btnLive.Enabled = false;
|
||||
_lblTrack.Text = "Starting live capture...";
|
||||
|
||||
_state.SetStrength('A', 60);
|
||||
_state.SetStrength('B', 60);
|
||||
_state.Stop('A');
|
||||
_state.Stop('B');
|
||||
|
||||
_liveCapture = new LiveCapture(_state, dev);
|
||||
_liveCapture.Stopped += () => BeginInvoke(StopLive);
|
||||
_liveCapture.Start();
|
||||
|
||||
_lblTrack.Text = "Live capture";
|
||||
RefreshButtonStates();
|
||||
}
|
||||
|
||||
void StopLive()
|
||||
{
|
||||
if (_liveCapture != null)
|
||||
{
|
||||
try { _liveCapture.Stop(); } catch { }
|
||||
try { _liveCapture.Dispose(); } catch { }
|
||||
_liveCapture = null;
|
||||
}
|
||||
|
||||
if (_isLiveRunning)
|
||||
{
|
||||
_isLiveRunning = false;
|
||||
if (!IsDisposed)
|
||||
{
|
||||
_lblTrack.Text = "";
|
||||
RefreshButtonStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateTrayIcon()
|
||||
{
|
||||
var newState = _state.IsSignaling ? "hot"
|
||||
|
||||
Reference in New Issue
Block a user