Compare commits

...

6 Commits

Author SHA1 Message Date
mute abb5364bb4 Bump version to 0.2.0 2026-08-08 19:55:19 +00:00
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
mute bbd9bb6d6b Add xToys pattern import via URL paste
- XToysPattern.cs: fetch pattern from xtoys.app API (one call), parse
  script-v3 JSON, resolve slider defaults, evaluate sine/straight steps
  to WaveFrames at 100ms resolution
- Frequency mapping: 0%=1000ms deep, 100%=10ms buzzy
- MainForm: Pattern button + input dialog, continuously enqueues pattern
  frames as a loop, Stop All cancels
- Fix: handle JSON values that are numbers vs strings (end/start fields)
- NOTES.md: xToys slider parameter intel + frequency mapping docs
2026-08-08 19:44:25 +00:00
mute ccc5093ee2 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
2026-08-08 19:20:46 +00:00
mute 933c7ede38 Move Swap to connect row, show limiter values, fix trackbar keys
- Swap checkbox moved up to the Connect/device line
- Limiter values shown as separate labels (plain, 32px wide for 3 digits)
- Trackbar keyboard: Up/PageUp/Home = increase, Down/PageDown/End = decrease
- Fix Lim B trackbar clipping (moved down to avoid overlap with Lim A)
2026-08-08 11:58:37 +00:00
mute 8b51ff19a8 Make UI event-driven, heat maps at 10Hz from tick loop
- Heat maps fed from tick loop via BeginInvoke (10Hz, one column per tick)
- BLE label updates on ConnectionChanged event (was 4Hz poll)
- Recv gauges update on StrengthChanged event (B1 notifications)
- Button states refreshed via RefreshButtonStates on all state changes
- Status timer reduced from 250ms to 1s — only music label + tray icon
- Eliminates 4Hz choppy polling
2026-08-08 11:38:05 +00:00
7 changed files with 705 additions and 280 deletions
+165
View File
@@ -0,0 +1,165 @@
using System.Numerics;
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<double> _sampleBuffer = new();
readonly object _bufferLock = new();
double[]? _prevRhythmMag;
double _liveMaxFlux;
double _liveMaxMelodyEnergy;
int _sampleRate;
int _fftsPerTick;
int _fftIndexInTick;
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 (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) =
MusicAnalyzer.ExtractFeatures(mag, _prevRhythmMag, _sampleRate);
_prevRhythmMag = mag;
// Adaptive normalization: running max with slow decay
_liveMaxFlux = Math.Max(rhythmFlux, _liveMaxFlux * 0.999);
_liveMaxMelodyEnergy = Math.Max(melodyEnergy, _liveMaxMelodyEnergy * 0.999);
tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux);
tf.MelodyEnergy += melodyEnergy;
tf.MelodyFreqSamples.Add(melodyFreq);
tf.MelodyCount++;
_fftIndexInTick++;
if (_fftIndexInTick >= _fftsPerTick)
{
var (frameA, frameB) = MusicAnalyzer.BuildWaveFrame(tf, _liveMaxFlux, _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 { }
}
}
+288 -130
View File
@@ -1,7 +1,6 @@
using System.ComponentModel;
using System.Drawing.Drawing2D;
using System.Diagnostics;
using NAudio.Wave;
using NAudio.CoreAudioApi;
namespace Substation;
@@ -36,19 +35,25 @@ public class MainForm : Form
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 _btnMusic;
readonly Button _btnLive;
readonly Button _btnPattern;
readonly Label _lblTrack;
bool _isMusicRunning;
WaveOutEvent? _audioOut;
Stopwatch? _musicStopwatch;
readonly Label _lblAudioDev;
readonly ComboBox _cboAudioDev;
bool _isLiveRunning;
bool _isPatternRunning;
LiveCapture? _liveCapture;
public MainForm(CoyoteDevice device, State state, Server server)
{
@@ -58,7 +63,7 @@ public class MainForm : Form
_loopCts = new CancellationTokenSource();
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
ClientSize = new Size(420, 410);
ClientSize = new Size(420, 450);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
@@ -96,39 +101,70 @@ public class MainForm : Form
_btnConnect = new Button
{
Text = "Connect",
Location = new Point(296, 64),
Size = new Size(108, 24)
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(100, 32)
Size = new Size(80, 32)
};
_btnTest.Click += OnTest;
_btnMusic = new Button
_btnLive = new Button
{
Text = "Music",
Location = new Point(130, 96),
Size = new Size(100, 32)
Text = "Live",
Location = new Point(104, 96),
Size = new Size(80, 32)
};
_btnMusic.Click += OnMusic;
_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(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
};
@@ -136,37 +172,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,
@@ -175,13 +211,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,
@@ -190,25 +226,33 @@ public class MainForm : Form
_lblLimitA = new Label
{
Text = "Lim A: 30",
Location = new Point(16, 274),
Size = new Size(56, 20),
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(72, 270),
Size = new Size(250, 45),
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(328, 274),
Location = new Point(320, 298),
Size = new Size(76, 24),
Checked = false
};
@@ -216,32 +260,40 @@ public class MainForm : Form
_lblLimitB = new Label
{
Text = "Lim B: 30",
Location = new Point(16, 312),
Size = new Size(56, 20),
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(72, 308),
Size = new Size(250, 45),
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(328, 312),
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, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _chkScaleA, _lblLimitB, _limitBarB, _chkScaleB });
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);
@@ -260,7 +312,7 @@ public class MainForm : Form
_tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) => ExitFromTray());
_tray.DoubleClick += (_, _) => ShowFromTray();
_statusTimer = new System.Windows.Forms.Timer { Interval = 250 };
_statusTimer = new System.Windows.Forms.Timer { Interval = 1000 };
_statusTimer.Tick += OnStatusTick;
_statusTimer.Start();
@@ -269,6 +321,8 @@ public class MainForm : Form
Resize += OnResize;
_server.ClientChanged += OnWsClientChanged;
_device.ConnectionChanged += OnDeviceConnectionChanged;
_device.StrengthChanged += OnDeviceStrengthChanged;
}
async void OnLoad(object? sender, EventArgs e)
@@ -297,6 +351,14 @@ public class MainForm : Form
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)
{
@@ -368,36 +430,55 @@ public class MainForm : Form
BeginInvoke(() =>
{
_lblWs.Text = connected ? "WS: client connected" : "WS: idle";
_btnTest.Enabled = !connected && !IsTestRunning;
_btnMusic.Enabled = !connected && !_isMusicRunning;
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;
_lblBle.Text = _device.IsConnected
? $"BLE: connected ({_device.DeviceName})"
: "BLE: disconnected";
_heatA.AddTick(
_state.LastFreqA ?? Array.Empty<byte>(),
_state.LastIntA ?? Array.Empty<byte>(),
_state.LastActiveA);
_heatB.AddTick(
_state.LastFreqB ?? Array.Empty<byte>(),
_state.LastIntB ?? Array.Empty<byte>(),
_state.LastActiveB);
_gaugeRecvA.Value = Math.Clamp(_device.StrengthA, 0, 200);
_gaugeRecvB.Value = Math.Clamp(_device.StrengthB, 0, 200);
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning;
_btnStop.Enabled = true;
if (_isMusicRunning && _musicStopwatch != null)
if (_isLiveRunning)
{
var elapsed = _musicStopwatch.Elapsed;
_lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}";
_lblTrack.Text = "Live capture";
}
else if (_isPatternRunning)
{
_lblTrack.Text = $"Pattern: {_patternName}";
}
UpdateTrayIcon();
@@ -457,13 +538,14 @@ public class MainForm : Form
BeginInvoke(() =>
{
IsTestRunning = false;
_btnTest.Enabled = !_server.HasClient;
RefreshButtonStates();
});
}
void OnStop(object? sender, EventArgs e)
{
StopMusic();
StopLive();
StopPattern();
_state.SetStrength('A', 0);
_state.SetStrength('B', 0);
_state.Stop('A');
@@ -478,111 +560,170 @@ public class MainForm : Form
var modeB = _chkScaleB.Checked ? LimitMode.Scale : LimitMode.Clamp;
_state.SetLimitA(maxA, modeA);
_state.SetLimitB(maxB, modeB);
_lblLimitA.Text = $"Lim A: {maxA}";
_lblLimitB.Text = $"Lim B: {maxB}";
_lblLimitValA.Text = $"{maxA}";
_lblLimitValB.Text = $"{maxB}";
}
string _musicFilePath = "";
TimeSpan _musicTotalTime;
void OnMusic(object? sender, EventArgs e)
void OnSwapChanged(object? sender, EventArgs e)
{
if (_isMusicRunning || _server.HasClient) return;
_state.SwapChannels = _chkSwap.Checked;
}
using var dlg = new OpenFileDialog
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++)
{
Filter = "MP3 files (*.mp3)|*.mp3|All files (*.*)|*.*",
Title = "Select music to drive e-stim"
};
if (dlg.ShowDialog() != DialogResult.OK) return;
_musicFilePath = dlg.FileName;
_musicTotalTime = TimeSpan.Zero;
_isMusicRunning = true;
_btnMusic.Enabled = false;
_btnTest.Enabled = false;
_lblTrack.Text = $"Analyzing... {Path.GetFileName(_musicFilePath)}";
var filePath = _musicFilePath;
Task.Run(() => RunMusicAsync(filePath));
_cboAudioDev.Items.Add(devices[i]);
if (devices[i].ID == defaultDev.ID)
defaultIdx = i;
}
_cboAudioDev.SelectedIndex = defaultIdx;
}
async Task RunMusicAsync(string filePath)
void OnLive(object? sender, EventArgs e)
{
MusicPattern? pattern = null;
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
{
var progress = new Progress<int>(pct =>
{
if (!IsDisposed)
_lblTrack.Text = $"Analyzing... {pct}% {Path.GetFileName(filePath)}";
});
pattern = await Task.Run(() => MusicAnalyzer.Analyze(filePath, (IProgress<int>)progress));
pattern = await XToysPatternImporter.ImportAsync(url);
}
catch (Exception ex)
{
BeginInvoke(() =>
{
_lblTrack.Text = $"Analysis failed: {ex.Message}";
_isMusicRunning = false;
_btnMusic.Enabled = !_server.HasClient;
_btnTest.Enabled = !_server.HasClient && !IsTestRunning;
_lblTrack.Text = $"Pattern failed: {ex.Message?.Split('\n')[0]}";
_isPatternRunning = false;
RefreshButtonStates();
});
return;
}
_musicTotalTime = pattern.Duration;
_patternName = pattern.Name;
// Set a moderate strength ceiling for music
_state.SetStrength('A', 60);
_state.SetStrength('B', 60);
// Clear any existing patterns and enqueue all music frames
_state.Stop('A');
_state.Stop('B');
_state.EnqueueStream('A', pattern.ChannelA);
_state.EnqueueStream('B', pattern.ChannelB);
// Start audio playback + stopwatch simultaneously
try
{
var reader = new AudioFileReader(filePath);
_audioOut = new WaveOutEvent();
_audioOut.Init(reader);
_audioOut.PlaybackStopped += (_, _) => BeginInvoke(StopMusic);
_musicStopwatch = Stopwatch.StartNew();
_audioOut.Play();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[music] playback failed: {ex.Message}");
_musicStopwatch = Stopwatch.StartNew();
}
BeginInvoke(() => _lblTrack.Text = $"Pattern: {_patternName}");
BeginInvoke(() => _lblTrack.Text = $"Playing 00:00 / {_musicTotalTime:mm\\:ss} {Path.GetFileName(filePath)}");
// 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 StopMusic()
void StopPattern()
{
if (_audioOut != null)
{
try { _audioOut.Stop(); } catch { }
try { _audioOut.Dispose(); } catch { }
_audioOut = null;
}
_musicStopwatch = null;
_patternCts?.Cancel();
_patternCts = null;
if (_isMusicRunning)
if (_isPatternRunning)
{
_isMusicRunning = false;
_isPatternRunning = false;
if (!IsDisposed)
{
_lblTrack.Text = "";
_btnMusic.Enabled = !_server.HasClient;
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"
@@ -598,6 +739,23 @@ public class MainForm : Form
};
}
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;
+48 -147
View File
@@ -1,135 +1,21 @@
using System.Numerics;
using FftSharp;
using NAudio.Wave;
namespace Substation;
public record MusicPattern(List<WaveFrame> ChannelA, List<WaveFrame> ChannelB, TimeSpan Duration);
public static class MusicAnalyzer
{
const int SampleRate = 44100;
const int WindowSize = 2048;
const int HopSize = 1024;
const double TickDuration = 0.1; // 100ms per e-stim frame
public const int WindowSize = 2048;
public const int HopSize = 1024;
public const double TickDuration = 0.1;
// Frequency band boundaries (Hz)
const double RhythmLow = 20, RhythmHigh = 250;
const double MelodyLow = 300, MelodyHigh = 4000;
public static MusicPattern Analyze(string mp3Path, IProgress<int>? progress = null)
public static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq)
ExtractFeatures(double[] magnitude, double[]? prevRhythmMag, int sampleRate)
{
var samples = DecodeToMono(mp3Path);
var duration = TimeSpan.FromSeconds((double)samples.Length / SampleRate);
var tickCount = (int)Math.Ceiling((double)samples.Length / SampleRate / TickDuration);
var channelA = new List<WaveFrame>(tickCount);
var channelB = new List<WaveFrame>(tickCount);
var window = new FftSharp.Windows.Hanning();
var buffer = new double[WindowSize];
int fftsPerTick = (int)Math.Round(TickDuration * SampleRate / HopSize);
double[]? prevRhythmMag = null;
double maxFlux = 0;
double maxMelodyEnergy = 0;
// First pass: collect per-tick features
var tickFeatures = new List<TickFeature>(tickCount);
int pos = 0;
int fftIndex = 0;
while (pos + WindowSize <= samples.Length)
{
for (int i = 0; i < WindowSize; i++)
buffer[i] = samples[pos + i];
window.ApplyInPlace(buffer);
var spectrum = FFT.Forward(buffer);
var mag = FFT.Magnitude(spectrum);
var (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) =
ExtractFeatures(mag, prevRhythmMag);
if (rhythmFlux > maxFlux) maxFlux = rhythmFlux;
if (melodyEnergy > maxMelodyEnergy) maxMelodyEnergy = melodyEnergy;
prevRhythmMag = mag;
int tickIdx = fftIndex / Math.Max(1, fftsPerTick);
while (tickFeatures.Count <= tickIdx)
tickFeatures.Add(new TickFeature());
var tf = tickFeatures[tickIdx];
tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux);
tf.MelodyEnergy += melodyEnergy;
tf.MelodyFreqSamples.Add(melodyFreq);
tf.MelodyCount++;
pos += HopSize;
fftIndex++;
if (progress != null && fftIndex % 50 == 0)
{
var pct = (int)((double)pos / samples.Length * 100);
progress.Report(pct);
}
}
progress?.Report(100);
// Second pass: normalize and build WaveFrames
const int rhythmFreqMs = 150; // ~7Hz deep pulse
foreach (var tf in tickFeatures)
{
// Channel A: rhythm onset
double normalizedFlux = maxFlux > 0 ? tf.RhythmFlux / maxFlux : 0;
int onsetIntensity = (int)Math.Round(normalizedFlux * 100);
// Sub-tick attack/decay shape
var intA = new[]
{
(byte)Math.Clamp(onsetIntensity, 0, 100),
(byte)Math.Clamp(onsetIntensity * 6 / 10, 0, 100),
(byte)Math.Clamp(onsetIntensity * 3 / 10, 0, 100),
(byte)0
};
var freqA = Freq.Compress4(new[] { rhythmFreqMs, rhythmFreqMs, rhythmFreqMs, rhythmFreqMs });
channelA.Add(new WaveFrame(freqA, intA));
// Channel B: melody
double avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0;
double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80); // cap at 80 for comfort
melodyIntensity = Math.Clamp(melodyIntensity, 0, 100);
// Weighted average dominant frequency
double weightedFreq = 0;
double totalWeight = 0;
foreach (var f in tf.MelodyFreqSamples)
{
weightedFreq += f * f; // weight by energy (freq already squared mag)
totalWeight += f;
}
double avgMelodyHz = totalWeight > 0 ? weightedFreq / totalWeight : 500;
int estimsMs = MapPitchToPeriod(avgMelodyHz);
var intB = new[]
{
(byte)melodyIntensity, (byte)melodyIntensity,
(byte)melodyIntensity, (byte)melodyIntensity
};
var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs });
channelB.Add(new WaveFrame(freqB, intB));
}
return new MusicPattern(channelA, channelB, duration);
}
static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq) ExtractFeatures(
double[] magnitude, double[]? prevRhythmMag)
{
double binWidth = (double)SampleRate / WindowSize;
double binWidth = (double)sampleRate / WindowSize;
int rhythmLoBin = (int)(RhythmLow / binWidth);
int rhythmHiBin = (int)(RhythmHigh / binWidth);
int melodyLoBin = (int)(MelodyLow / binWidth);
@@ -172,7 +58,7 @@ public static class MusicAnalyzer
return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq);
}
static int MapPitchToPeriod(double hz)
public static int MapPitchToPeriod(double hz)
{
// Map melody frequency (300-4000Hz) to e-stim period (10-1000ms)
// Logarithmic mapping: low notes → deep, high notes → buzzy
@@ -185,36 +71,51 @@ public static class MusicAnalyzer
return Math.Clamp(ms, 10, 1000);
}
static double[] DecodeToMono(string mp3Path)
{
using var reader = new Mp3FileReader(mp3Path);
var format = new WaveFormat(SampleRate, 16, 1);
using var resampler = new MediaFoundationResampler(reader, format);
resampler.ResamplerQuality = 60;
var sampleList = new List<float>();
var buffer = new byte[SampleRate * 2]; // 1s worth of 16-bit mono
int read;
while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0)
{
for (int i = 0; i < read; i += 2)
{
short sample = (short)(buffer[i] | (buffer[i + 1] << 8));
sampleList.Add(sample / 32768f);
}
}
var result = new double[sampleList.Count];
for (int i = 0; i < sampleList.Count; i++)
result[i] = sampleList[i];
return result;
}
class TickFeature
public class TickFeature
{
public double RhythmFlux;
public double MelodyEnergy;
public double MelodyCount;
public readonly List<double> MelodyFreqSamples = new();
}
public static (WaveFrame chA, WaveFrame chB) BuildWaveFrame(TickFeature tf, double maxFlux, double maxMelodyEnergy)
{
const int rhythmFreqMs = 150;
double normalizedFlux = maxFlux > 0 ? tf.RhythmFlux / maxFlux : 0;
int onsetIntensity = (int)Math.Round(normalizedFlux * 100);
var intA = new[]
{
(byte)Math.Clamp(onsetIntensity, 0, 100),
(byte)Math.Clamp(onsetIntensity * 6 / 10, 0, 100),
(byte)Math.Clamp(onsetIntensity * 3 / 10, 0, 100),
(byte)0
};
var freqA = Freq.Compress4(new[] { rhythmFreqMs, rhythmFreqMs, rhythmFreqMs, rhythmFreqMs });
double avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0;
double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80);
melodyIntensity = Math.Clamp(melodyIntensity, 0, 100);
double weightedFreq = 0;
double totalWeight = 0;
foreach (var f in tf.MelodyFreqSamples)
{
weightedFreq += f * f;
totalWeight += f;
}
double avgMelodyHz = totalWeight > 0 ? weightedFreq / totalWeight : 500;
int estimsMs = MapPitchToPeriod(avgMelodyHz);
var intB = new[]
{
(byte)melodyIntensity, (byte)melodyIntensity,
(byte)melodyIntensity, (byte)melodyIntensity
};
var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs });
return (new WaveFrame(freqA, intA), new WaveFrame(freqB, intB));
}
}
+25
View File
@@ -129,3 +129,28 @@ web game / userscript ──WS──> Substation ──BLE──> Coyote 3.0
```
Build: `dotnet run -c Release`
## xToys pattern import
Paste a URL like `https://xtoys.app/patterns/-OuhuHOuY1AlPPoCJlzc` into the Pattern dialog.
The app fetches the pattern once from `https://xtoys.app/api/getPatternv2`, evaluates it
locally, and plays it as a continuous loop. No ongoing API calls.
### xToys script-v3 slider parameters
From pattern analysis:
- **A CH** (0.510): Divides the pattern between channels. If A=1 and B=2, then 2/3 of
the pattern goes to channel B. Controls the "hold" duration on channel A.
- **B CH** (0.510): Same division for channel B. Controls the "pause" duration.
- **Ramp** (0.55): Applies a smoothness filter to transitions. Higher = slower ramps.
- **Min Freq** (0100): Low cutoff on frequency values. No idea why.
- **Min Level** (0100): Low cutoff on intensity values. Floor that's held during "pause".
### Frequency mapping
xToys frequency is 0100 (percentage). Mapped to e-stim period:
- 0% = 1000ms (1Hz, deep thump)
- 100% = 10ms (100Hz, buzzy)
Formula: `period_ms = 1000 - 9.9 * freq_pct`
+14
View File
@@ -25,6 +25,7 @@ public class State
public int MaxB = 200;
public LimitMode LimitModeA = LimitMode.Clamp;
public LimitMode LimitModeB = LimitMode.Clamp;
public bool SwapChannels;
public void SetLimitA(int max, LimitMode mode)
{
@@ -129,6 +130,19 @@ public class State
LastIntensityA = hasA ? (intA[0] + intA[1] + intA[2] + intA[3]) / 4 : 0;
LastIntensityB = hasB ? (intB[0] + intB[1] + intB[2] + intB[3]) / 4 : 0;
if (SwapChannels)
{
(valA, valB) = (valB, valA);
(modeA, modeB) = (modeB, modeA);
(freqA, freqB) = (freqB, freqA);
(intA, intB) = (intB, intA);
(LastSentA, LastSentB) = (LastSentB, LastSentA);
(LastFreqA, LastFreqB) = (LastFreqB, LastFreqA);
(LastIntA, LastIntB) = (LastIntB, LastIntA);
(LastActiveA, LastActiveB) = (LastActiveB, LastActiveA);
(LastIntensityA, LastIntensityB) = (LastIntensityB, LastIntensityA);
}
return (modeA, valA, modeB, valB, freqA, intA, freqB, intB);
}
}
+3 -3
View File
@@ -11,9 +11,9 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>Substation</AssemblyName>
<RootNamespace>Substation</RootNamespace>
<Version>0.1.6</Version>
<AssemblyVersion>0.1.6</AssemblyVersion>
<FileVersion>0.1.6</FileVersion>
<Version>0.2.0</Version>
<AssemblyVersion>0.2.0</AssemblyVersion>
<FileVersion>0.2.0</FileVersion>
</PropertyGroup>
<ItemGroup>
+162
View File
@@ -0,0 +1,162 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Substation;
public record XToysPattern(string Name, List<WaveFrame> ChannelA, List<WaveFrame> ChannelB);
public static class XToysPatternImporter
{
static readonly HttpClient Http = new();
public static async Task<XToysPattern> ImportAsync(string url)
{
var patternId = ExtractPatternId(url);
if (patternId == null)
throw new ArgumentException("Could not extract pattern ID from URL. Expected format: https://xtoys.app/patterns/<ID>");
var body = JsonSerializer.Serialize(new { data = new { patternID = patternId } });
var content = new StringContent(body, Encoding.UTF8, "application/json");
var response = await Http.PostAsync("https://xtoys.app/api/getPatternv2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return Parse(json);
}
static int FreqPctToMs(byte freqPct)
{
// xToys frequency: 0% = deep (1000ms), 100% = buzzy (10ms)
// freqPct is the raw 0-100 value from the pattern (already clamped in EvaluatePattern)
return (int)Math.Round(1000 - 9.9 * freqPct);
}
static string? ExtractPatternId(string url)
{
var match = Regex.Match(url, @"/patterns/([A-Za-z0-9_-]+)");
return match.Success ? match.Groups[1].Value : null;
}
static XToysPattern Parse(string json)
{
using var doc = JsonDocument.Parse(json);
var pattern = doc.RootElement.GetProperty("result").GetProperty("pattern");
var name = pattern.GetProperty("name").GetString() ?? "Unknown";
var patternData = pattern.GetProperty("patternData");
// Resolve slider defaults
var sliders = new Dictionary<string, double>();
if (patternData.TryGetProperty("ma", out var ma))
{
foreach (var slider in ma.EnumerateArray())
{
var key = slider.GetProperty("key").GetString()!;
var value = slider.GetProperty("value").GetDouble();
sliders[key] = value;
}
}
// Evaluate patterns to per-tick value arrays
var int1 = EvaluatePattern(patternData, "Int1", sliders);
var int2 = EvaluatePattern(patternData, "Int2", sliders);
var freq1 = EvaluatePattern(patternData, "Freq1", sliders);
var freq2 = EvaluatePattern(patternData, "Freq2", sliders);
// All patterns in a group should have the same total duration.
// Use the max length to be safe.
int tickCount = Math.Max(int1.Length, Math.Max(int2.Length, Math.Max(freq1.Length, freq2.Length)));
var channelA = new List<WaveFrame>(tickCount);
var channelB = new List<WaveFrame>(tickCount);
for (int i = 0; i < tickCount; i++)
{
byte intA = i < int1.Length ? int1[i] : (byte)0;
byte intB = i < int2.Length ? int2[i] : (byte)0;
// Freq patterns output 0-100 (percentage). Map: 0% = 1000ms deep, 100% = 10ms buzzy.
byte freqA = i < freq1.Length ? Freq.Compress(FreqPctToMs(freq1[i])) : Freq.Compress(10);
byte freqB = i < freq2.Length ? Freq.Compress(FreqPctToMs(freq2[i])) : Freq.Compress(10);
channelA.Add(new WaveFrame(
new[] { freqA, freqA, freqA, freqA },
new[] { intA, intA, intA, intA }));
channelB.Add(new WaveFrame(
new[] { freqB, freqB, freqB, freqB },
new[] { intB, intB, intB, intB }));
}
return new XToysPattern(name, channelA, channelB);
}
static byte[] EvaluatePattern(JsonElement patternData, string patternName, Dictionary<string, double> sliders)
{
if (!patternData.TryGetProperty("patterns", out var patterns))
return Array.Empty<byte>();
if (!patterns.TryGetProperty(patternName, out var patternArr))
return Array.Empty<byte>();
var values = new List<byte>();
double lastEnd = 0;
foreach (var loop in patternArr.EnumerateArray())
{
var steps = loop.GetProperty("steps");
foreach (var step in steps.EnumerateArray())
{
var type = step.GetProperty("type").GetString() ?? "straight";
var time = ResolveExpr(GetStepValue(step, "time"), sliders);
var startVal = step.TryGetProperty("start", out var s)
? (s.ValueKind == JsonValueKind.Null ? lastEnd : ResolveExpr(GetStepValue(step, "start"), sliders))
: lastEnd;
var endVal = ResolveExpr(GetStepValue(step, "end"), sliders);
int ticks = Math.Max(1, (int)Math.Round(time / 0.1));
for (int t = 0; t < ticks; t++)
{
double frac = (double)t / ticks;
double v;
if (type == "sine")
v = startVal + (endVal - startVal) * (1 - Math.Cos(frac * Math.PI)) / 2;
else
v = startVal + (endVal - startVal) * frac;
values.Add((byte)Math.Clamp((int)Math.Round(v), 0, 100));
}
lastEnd = endVal;
}
}
return values.ToArray();
}
static string GetStepValue(JsonElement step, string propName)
{
if (!step.TryGetProperty(propName, out var el))
return "0";
return el.ValueKind switch
{
JsonValueKind.String => el.GetString() ?? "0",
JsonValueKind.Number => el.GetDouble().ToString(),
_ => "0"
};
}
static double ResolveExpr(string expr, Dictionary<string, double> sliders)
{
// Replace {var} references, then evaluate simple expressions like "ramp/2"
var resolved = Regex.Replace(expr, @"\{(\w+)\}", m =>
sliders.TryGetValue(m.Groups[1].Value, out var val) ? val.ToString() : "0");
// Parse simple arithmetic: number, or number/number
var parts = resolved.Split('/');
if (parts.Length == 2 && double.TryParse(parts[0], out var a) && double.TryParse(parts[1], out var b))
return a / b;
return double.TryParse(resolved, out var result) ? result : 0;
}
}