Files
Substation/MainForm.cs
T
mute e43b1f30cd Remove music button and MP3 decode, keep live capture only
- Remove Music button, OnMusic/RunMusicAsync/StopMusic and all music fields
- MusicAnalyzer: remove Analyze, DecodeToMono, MusicPattern record;
  keep ExtractFeatures/MapPitchToPeriod/BuildWaveFrame/TickFeature (used by LiveCapture)
- Remove unused NAudio.Wave and System.Diagnostics imports from MainForm
- Fix LiveCapture overlap: sliding window with 50% overlap (was dropping every other frame)
- Buttons resized to 80px (Test/Live/Pattern/Stop All)
2026-08-08 19:52:56 +00:00

790 lines
24 KiB
C#

using System.ComponentModel;
using System.Drawing.Drawing2D;
using NAudio.CoreAudioApi;
namespace Substation;
public class MainForm : Form
{
readonly CoyoteDevice _device;
readonly State _state;
readonly Server _server;
readonly NotifyIcon _tray;
readonly Label _lblBle;
readonly Label _lblWs;
readonly Label _lblDeviceName;
readonly TextBox _txtDeviceName;
readonly Button _btnConnect;
readonly Button _btnTest;
readonly Button _btnStop;
readonly System.Windows.Forms.Timer _statusTimer;
readonly CancellationTokenSource _loopCts;
bool _closingFromTray;
readonly HeatMap _heatA;
readonly HeatMap _heatB;
readonly ProgressBar _gaugeRecvA;
readonly ProgressBar _gaugeRecvB;
readonly Label _lblSendA;
readonly Label _lblSendB;
readonly Label _lblRecvA;
readonly Label _lblRecvB;
readonly TrackBar _limitBarA;
readonly TrackBar _limitBarB;
readonly Label _lblLimitA;
readonly Label _lblLimitB;
readonly Label _lblLimitValA;
readonly Label _lblLimitValB;
readonly CheckBox _chkScaleA;
readonly CheckBox _chkScaleB;
readonly CheckBox _chkSwap;
readonly Icon _iconNeutral;
readonly Icon _iconActive;
readonly Icon _iconHot;
string _trayState = "";
readonly Button _btnLive;
readonly Button _btnPattern;
readonly Label _lblTrack;
readonly Label _lblAudioDev;
readonly ComboBox _cboAudioDev;
bool _isLiveRunning;
bool _isPatternRunning;
LiveCapture? _liveCapture;
public MainForm(CoyoteDevice device, State state, Server server)
{
_device = device;
_state = state;
_server = server;
_loopCts = new CancellationTokenSource();
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
ClientSize = new Size(420, 450);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
ShowInTaskbar = true;
_lblBle = new Label
{
Text = "BLE: searching...",
Location = new Point(16, 16),
Size = new Size(388, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblWs = new Label
{
Text = "WS: idle",
Location = new Point(16, 40),
Size = new Size(388, 20),
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
};
_lblDeviceName = new Label
{
Text = "Device:",
Location = new Point(16, 66),
Size = new Size(48, 20)
};
_txtDeviceName = new TextBox
{
Text = "47L121000",
Location = new Point(68, 64),
Size = new Size(160, 20)
};
_btnConnect = new Button
{
Text = "Connect",
Location = new Point(236, 64),
Size = new Size(80, 24)
};
_btnConnect.Click += OnConnect;
_chkSwap = new CheckBox
{
Text = "Swap",
Location = new Point(328, 66),
Size = new Size(56, 20),
Checked = false
};
_chkSwap.CheckedChanged += OnSwapChanged;
_btnTest = new Button
{
Text = "Test",
Location = new Point(16, 96),
Size = new Size(80, 32)
};
_btnTest.Click += OnTest;
_btnLive = new Button
{
Text = "Live",
Location = new Point(104, 96),
Size = new Size(80, 32)
};
_btnLive.Click += OnLive;
_btnPattern = new Button
{
Text = "Pattern",
Location = new Point(192, 96),
Size = new Size(80, 32)
};
_btnPattern.Click += OnPattern;
_btnStop = new Button
{
Text = "Stop All",
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, 158),
Size = new Size(388, 16),
ForeColor = Color.DimGray
};
_lblSendA = new Label
{
Text = "Send A",
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, 182),
Size = new Size(332, 22)
};
_lblSendB = new Label
{
Text = "Send B",
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, 210),
Size = new Size(332, 22)
};
_lblRecvA = new Label
{
Text = "Recv A",
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, 240),
Size = new Size(332, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblRecvB = new Label
{
Text = "Recv B",
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, 266),
Size = new Size(332, 20),
Minimum = 0,
Maximum = 200,
Style = ProgressBarStyle.Continuous
};
_lblLimitA = new Label
{
Text = "Lim A",
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, 294),
Size = new Size(220, 45),
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
_limitBarA.ValueChanged += OnLimitChanged;
FixTrackbarKeys(_limitBarA);
_lblLimitValA = new Label
{
Text = "30",
Location = new Point(286, 298),
Size = new Size(32, 20),
TextAlign = ContentAlignment.MiddleRight
};
_chkScaleA = new CheckBox
{
Text = "Scale",
Location = new Point(320, 298),
Size = new Size(76, 24),
Checked = false
};
_chkScaleA.CheckedChanged += OnLimitChanged;
_lblLimitB = new Label
{
Text = "Lim B",
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, 342),
Size = new Size(220, 45),
Minimum = 0,
Maximum = 200,
TickFrequency = 50,
Value = 30
};
_limitBarB.ValueChanged += OnLimitChanged;
FixTrackbarKeys(_limitBarB);
_lblLimitValB = new Label
{
Text = "30",
Location = new Point(286, 346),
Size = new Size(32, 20),
TextAlign = ContentAlignment.MiddleRight
};
_chkScaleB = new CheckBox
{
Text = "Scale",
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, _btnLive, _btnPattern, _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);
_iconActive = CreateVoltageIcon(Color.Gold);
_iconHot = CreateVoltageIcon(Color.OrangeRed);
Icon = _iconActive;
_tray = new NotifyIcon
{
Icon = _iconActive,
Visible = true,
Text = "Substation"
};
_tray.ContextMenuStrip = new ContextMenuStrip();
_tray.ContextMenuStrip.Items.Add("Show", null, (_, _) => ShowFromTray());
_tray.ContextMenuStrip.Items.Add("-");
_tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) => ExitFromTray());
_tray.DoubleClick += (_, _) => ShowFromTray();
_statusTimer = new System.Windows.Forms.Timer { Interval = 1000 };
_statusTimer.Tick += OnStatusTick;
_statusTimer.Start();
Load += OnLoad;
FormClosing += OnFormClosing;
Resize += OnResize;
_server.ClientChanged += OnWsClientChanged;
_device.ConnectionChanged += OnDeviceConnectionChanged;
_device.StrengthChanged += OnDeviceStrengthChanged;
}
async void OnLoad(object? sender, EventArgs e)
{
_ = _server.RunAsync(CancellationToken.None);
_ = Task.Run(() => TickLoop(_loopCts.Token));
try
{
await _device.ConnectAsync(_txtDeviceName.Text);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BLE] auto-connect failed: {ex.Message}");
}
}
async Task TickLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
if (_device.IsConnected)
{
var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB);
await _device.SendB0(frame);
}
if (!IsDisposed)
{
BeginInvoke(() =>
{
_heatA.AddTick(freqA, intA, _state.LastActiveA);
_heatB.AddTick(freqB, intB, _state.LastActiveB);
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[tick] error: {ex.Message}");
}
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
}
}
void ShowFromTray()
{
Show();
WindowState = FormWindowState.Normal;
Activate();
}
void ExitFromTray()
{
_closingFromTray = true;
_tray.Visible = false;
Application.Exit();
}
void OnResize(object? sender, EventArgs e)
{
if (WindowState == FormWindowState.Minimized)
Hide();
}
void OnFormClosing(object? sender, FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing && !_closingFromTray)
{
e.Cancel = true;
Hide();
return;
}
_tray.Visible = false;
_statusTimer.Stop();
_loopCts.Cancel();
_server.Stop();
}
void OnConnect(object? sender, EventArgs e)
{
if (_device.IsConnected) return;
_btnConnect.Enabled = false;
_lblBle.Text = "BLE: connecting...";
Task.Run(async () =>
{
try
{
await _device.ConnectAsync(_txtDeviceName.Text);
}
catch (Exception ex)
{
BeginInvoke(() => _lblBle.Text = $"BLE: error - {ex.Message}");
}
finally
{
BeginInvoke(() => _btnConnect.Enabled = true);
}
});
}
void OnWsClientChanged(bool connected)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
_lblWs.Text = connected ? "WS: client connected" : "WS: idle";
RefreshButtonStates();
UpdateTrayIcon();
});
}
void OnDeviceConnectionChanged(bool connected)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
_lblBle.Text = connected
? $"BLE: connected ({_device.DeviceName})"
: "BLE: disconnected";
_btnConnect.Enabled = !connected;
RefreshButtonStates();
});
}
void OnDeviceStrengthChanged(int a, int b)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
if (_state.SwapChannels) (a, b) = (b, a);
_gaugeRecvA.Value = Math.Clamp(a, 0, 200);
_gaugeRecvB.Value = Math.Clamp(b, 0, 200);
});
}
void RefreshButtonStates()
{
bool busy = IsTestRunning || _isLiveRunning || _isPatternRunning;
_btnTest.Enabled = !_server.HasClient && !busy;
_btnLive.Enabled = !_server.HasClient && !busy;
_btnPattern.Enabled = !_server.HasClient && !busy;
_btnStop.Enabled = true;
}
void OnStatusTick(object? sender, EventArgs e)
{
if (IsDisposed) return;
if (_isLiveRunning)
{
_lblTrack.Text = "Live capture";
}
else if (_isPatternRunning)
{
_lblTrack.Text = $"Pattern: {_patternName}";
}
UpdateTrayIcon();
}
bool IsTestRunning;
void OnTest(object? sender, EventArgs e)
{
if (IsTestRunning || _server.HasClient) return;
IsTestRunning = true;
_btnTest.Enabled = false;
Task.Run(() => RunTestAsync());
}
async Task RunTestAsync()
{
// Short feel-good pattern over both channels (~3s), gentle swell, A leads B.
const int testStrength = 25;
_state.SetStrength('A', testStrength);
_state.SetStrength('B', testStrength);
// 30 frames x 100ms = 3 seconds. Deep-ish 7Hz pulse with breathing envelope.
var framesA = new List<WaveFrame>();
var framesB = new List<WaveFrame>();
for (int i = 0; i < 30; i++)
{
double t = (double)i / 30;
// Swell 0 -> 70 -> 0 over the run
double env = Math.Sin(Math.PI * t) * 70;
int intA = (int)Math.Round(env);
int intB = (int)Math.Round(env * 0.7); // B slightly softer
int freqMs = 150; // ~7Hz deep
framesA.Add(new WaveFrame(
Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }),
Intensity.Clamp4(new[] { intA, intA, intA, intA })));
framesB.Add(new WaveFrame(
Freq.Compress4(new[] { freqMs, freqMs, freqMs, freqMs }),
Intensity.Clamp4(new[] { intB, intB, intB, intB })));
}
_state.Stop('A'); _state.Stop('B');
_state.EnqueueStream('A', framesA);
_state.EnqueueStream('B', framesB);
// Wait for the stream to be consumed (~3s) plus margin, then silence.
await Task.Delay(3300);
_state.SetStrength('A', 0);
_state.SetStrength('B', 0);
_state.Stop('A');
_state.Stop('B');
BeginInvoke(() =>
{
IsTestRunning = false;
RefreshButtonStates();
});
}
void OnStop(object? sender, EventArgs e)
{
StopLive();
StopPattern();
_state.SetStrength('A', 0);
_state.SetStrength('B', 0);
_state.Stop('A');
_state.Stop('B');
}
void OnLimitChanged(object? sender, EventArgs e)
{
var maxA = _limitBarA.Value;
var maxB = _limitBarB.Value;
var modeA = _chkScaleA.Checked ? LimitMode.Scale : LimitMode.Clamp;
var modeB = _chkScaleB.Checked ? LimitMode.Scale : LimitMode.Clamp;
_state.SetLimitA(maxA, modeA);
_state.SetLimitB(maxB, modeB);
_lblLimitValA.Text = $"{maxA}";
_lblLimitValB.Text = $"{maxB}";
}
void OnSwapChanged(object? sender, EventArgs e)
{
_state.SwapChannels = _chkSwap.Checked;
}
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();
}
}
}
string _patternName = "";
CancellationTokenSource? _patternCts;
void OnPattern(object? sender, EventArgs e)
{
if (_isPatternRunning || _server.HasClient) return;
var url = ShowInputDialog("Paste xToys pattern URL:", "Pattern Import");
if (string.IsNullOrWhiteSpace(url)) return;
_isPatternRunning = true;
_btnPattern.Enabled = false;
_lblTrack.Text = "Loading pattern...";
_patternCts = new CancellationTokenSource();
Task.Run(() => RunPatternAsync(url, _patternCts.Token));
}
async Task RunPatternAsync(string url, CancellationToken ct)
{
XToysPattern? pattern = null;
try
{
pattern = await XToysPatternImporter.ImportAsync(url);
}
catch (Exception ex)
{
BeginInvoke(() =>
{
_lblTrack.Text = $"Pattern failed: {ex.Message?.Split('\n')[0]}";
_isPatternRunning = false;
RefreshButtonStates();
});
return;
}
_patternName = pattern.Name;
_state.SetStrength('A', 60);
_state.SetStrength('B', 60);
_state.Stop('A');
_state.Stop('B');
BeginInvoke(() => _lblTrack.Text = $"Pattern: {_patternName}");
// Continuously enqueue pattern frames at 100ms per frame
int idx = 0;
while (!ct.IsCancellationRequested && _isPatternRunning)
{
var frameA = pattern.ChannelA[idx];
var frameB = pattern.ChannelB[idx];
_state.EnqueueStream('A', new[] { frameA });
_state.EnqueueStream('B', new[] { frameB });
idx = (idx + 1) % pattern.ChannelA.Count;
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
}
}
void StopPattern()
{
_patternCts?.Cancel();
_patternCts = null;
if (_isPatternRunning)
{
_isPatternRunning = false;
if (!IsDisposed)
{
_lblTrack.Text = "";
RefreshButtonStates();
}
}
}
string ShowInputDialog(string prompt, string title)
{
using var dlg = new Form
{
Text = title,
FormBorderStyle = FormBorderStyle.FixedDialog,
ClientSize = new Size(380, 100),
StartPosition = FormStartPosition.CenterParent,
MaximizeBox = false,
MinimizeBox = false
};
var lbl = new Label { Text = prompt, Location = new Point(12, 12), Size = new Size(356, 20) };
var txt = new TextBox { Location = new Point(12, 36), Size = new Size(356, 22) };
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(200, 66), Size = new Size(80, 24) };
var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(288, 66), Size = new Size(80, 24) };
dlg.Controls.AddRange(new Control[] { lbl, txt, ok, cancel });
dlg.AcceptButton = ok;
dlg.CancelButton = cancel;
return dlg.ShowDialog(this) == DialogResult.OK ? txt.Text.Trim() : "";
}
void UpdateTrayIcon()
{
var newState = _state.IsSignaling ? "hot"
: _server.HasClient ? "active"
: "neutral";
if (newState == _trayState) return;
_trayState = newState;
_tray.Icon = newState switch
{
"hot" => _iconHot,
"active" => _iconActive,
_ => _iconNeutral
};
}
static void FixTrackbarKeys(TrackBar tb)
{
tb.KeyDown += (s, e) =>
{
if (s is not TrackBar t) return;
int step = e.KeyCode == Keys.Up || e.KeyCode == Keys.Down ? 1
: e.KeyCode == Keys.PageUp || e.KeyCode == Keys.PageDown ? 10
: 0;
if (step == 0) return;
if (e.KeyCode is Keys.Up or Keys.PageUp or Keys.Home)
t.Value = Math.Min(t.Maximum, t.Value + (e.KeyCode == Keys.Home ? t.Maximum : step));
else
t.Value = Math.Max(t.Minimum, t.Value - (e.KeyCode == Keys.End ? t.Maximum : step));
e.Handled = true;
};
}
static Icon CreateVoltageIcon(Color boltColor)
{
const int sz = 32;
using var bmp = new Bitmap(sz, sz);
using var g = Graphics.FromImage(bmp);
g.SmoothingMode = SmoothingMode.AntiAlias;
// Dark panel background with rounded corners.
using var bg = new SolidBrush(Color.FromArgb(45, 45, 48));
using var path = new GraphicsPath();
int r = 6, pad = 1;
var rect = new Rectangle(pad, pad, sz - 2 * pad, sz - 2 * pad);
path.AddArc(rect.X, rect.Y, r, r, 180, 90);
path.AddArc(rect.Right - r, rect.Y, r, r, 270, 90);
path.AddArc(rect.Right - r, rect.Bottom - r, r, r, 0, 90);
path.AddArc(rect.X, rect.Bottom - r, r, r, 90, 90);
path.CloseFigure();
g.FillPath(bg, path);
// Generic voltage symbol: a bold lightning bolt in the given colour.
var pts = new Point[]
{
new(21, 4), new(10, 18), new(16, 18),
new(13, 28), new(24, 13), new(17, 13)
};
using var bolt = new SolidBrush(boltColor);
g.FillPolygon(bolt, pts);
return Icon.FromHandle(bmp.GetHicon());
}
}