Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce20544a4 | |||
| 496cc5a6b2 | |||
| 1ab397767a | |||
| badc599dee | |||
| 5efdd63212 | |||
| f1e7d976ca | |||
| 4e36580b22 | |||
| 4ad85f57ec | |||
| 4c44d48a02 | |||
| abb5364bb4 | |||
| e43b1f30cd | |||
| bbd9bb6d6b | |||
| ccc5093ee2 | |||
| 933c7ede38 | |||
| 8b51ff19a8 | |||
| 33e289d596 | |||
| 325022d27c | |||
| 84eba7d094 | |||
| 612385af7f |
+1
-1
@@ -33,7 +33,7 @@ public class CoyoteDevice : IDisposable
|
||||
selector = BluetoothLEDevice.GetDeviceSelectorFromPairingState(false);
|
||||
devices = await DeviceInformation.FindAllAsync(selector);
|
||||
var match = devices.FirstOrDefault(d =>
|
||||
d.Name.Contains("47L121000", StringComparison.OrdinalIgnoreCase) ||
|
||||
d.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase) ||
|
||||
d.Name.Contains("DG-LAB", StringComparison.OrdinalIgnoreCase) ||
|
||||
d.Name.Contains("Coyote", StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
namespace Substation;
|
||||
|
||||
public class DrumDetector
|
||||
{
|
||||
const int HistorySize = 50;
|
||||
|
||||
readonly double[] _lowFluxHistory = new double[HistorySize];
|
||||
readonly double[] _midFluxHistory = new double[HistorySize];
|
||||
readonly double[] _highFluxHistory = new double[HistorySize];
|
||||
int _historyIndex;
|
||||
|
||||
double[]? _prevLowMag;
|
||||
double[]? _prevMidMag;
|
||||
double[]? _prevHighMag;
|
||||
|
||||
readonly bool[] _subKick = new bool[4];
|
||||
readonly bool[] _subSnare = new bool[4];
|
||||
readonly bool[] _subBrass = new bool[4];
|
||||
|
||||
public void ProcessWindow(double[] magnitude, int sampleRate, int windowSize, int subTickIndex)
|
||||
{
|
||||
if (subTickIndex < 0 || subTickIndex > 3) return;
|
||||
|
||||
double binWidth = (double)sampleRate / windowSize;
|
||||
int lowLo = (int)(20 / binWidth), lowHi = (int)(150 / binWidth);
|
||||
int midLo = (int)(200 / binWidth), midHi = (int)(500 / binWidth);
|
||||
int highLo = (int)(5000 / binWidth), highHi = (int)(15000 / binWidth);
|
||||
|
||||
var lowMag = ExtractBand(magnitude, lowLo, lowHi);
|
||||
var midMag = ExtractBand(magnitude, midLo, midHi);
|
||||
var highMag = ExtractBand(magnitude, highLo, highHi);
|
||||
|
||||
double lowFlux = ComputeFlux(lowMag, _prevLowMag);
|
||||
double midFlux = ComputeFlux(midMag, _prevMidMag);
|
||||
double highFlux = ComputeFlux(highMag, _prevHighMag);
|
||||
|
||||
_prevLowMag = lowMag;
|
||||
_prevMidMag = midMag;
|
||||
_prevHighMag = highMag;
|
||||
|
||||
// Update history and compute adaptive thresholds
|
||||
_lowFluxHistory[_historyIndex] = lowFlux;
|
||||
_midFluxHistory[_historyIndex] = midFlux;
|
||||
_highFluxHistory[_historyIndex] = highFlux;
|
||||
_historyIndex = (_historyIndex + 1) % HistorySize;
|
||||
|
||||
double lowThresh = AdaptiveThreshold(_lowFluxHistory);
|
||||
double midThresh = AdaptiveThreshold(_midFluxHistory);
|
||||
double highThresh = AdaptiveThreshold(_highFluxHistory);
|
||||
|
||||
// Detect onsets
|
||||
bool kickOnset = lowFlux > lowThresh && lowFlux > 0.001;
|
||||
bool snareOnset = midFlux > midThresh && highFlux > highThresh && midFlux > 0.001;
|
||||
bool brassOnset = highFlux > highThresh && !snareOnset && highFlux > 0.001;
|
||||
|
||||
if (kickOnset) _subKick[subTickIndex] = true;
|
||||
if (snareOnset) _subSnare[subTickIndex] = true;
|
||||
if (brassOnset) _subBrass[subTickIndex] = true;
|
||||
}
|
||||
|
||||
public WaveFrame BuildFrame()
|
||||
{
|
||||
var intensity = new byte[4];
|
||||
var freqBytes = new byte[4];
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
byte freq;
|
||||
if (_subKick[i])
|
||||
{
|
||||
intensity[i] = 100;
|
||||
freq = Freq.Compress(150);
|
||||
}
|
||||
else if (_subSnare[i])
|
||||
{
|
||||
intensity[i] = 100;
|
||||
freq = Freq.Compress(50);
|
||||
}
|
||||
else if (_subBrass[i])
|
||||
{
|
||||
intensity[i] = 100;
|
||||
freq = Freq.Compress(10);
|
||||
}
|
||||
else
|
||||
{
|
||||
intensity[i] = 0;
|
||||
freq = 10;
|
||||
}
|
||||
|
||||
freqBytes[i] = freq;
|
||||
}
|
||||
|
||||
// Reset for next tick
|
||||
Array.Clear(_subKick, 0, 4);
|
||||
Array.Clear(_subSnare, 0, 4);
|
||||
Array.Clear(_subBrass, 0, 4);
|
||||
|
||||
return new WaveFrame(freqBytes, intensity);
|
||||
}
|
||||
|
||||
static double[] ExtractBand(double[] magnitude, int loBin, int hiBin)
|
||||
{
|
||||
if (hiBin <= loBin || hiBin >= magnitude.Length)
|
||||
return Array.Empty<double>();
|
||||
|
||||
var band = new double[hiBin - loBin + 1];
|
||||
Array.Copy(magnitude, loBin, band, 0, band.Length);
|
||||
return band;
|
||||
}
|
||||
|
||||
static double ComputeFlux(double[] current, double[]? previous)
|
||||
{
|
||||
if (previous == null || current.Length != previous.Length) return 0;
|
||||
|
||||
double flux = 0;
|
||||
for (int i = 0; i < current.Length; i++)
|
||||
{
|
||||
double diff = current[i] - previous[i];
|
||||
if (diff > 0) flux += diff;
|
||||
}
|
||||
return flux;
|
||||
}
|
||||
|
||||
static double AdaptiveThreshold(double[] history)
|
||||
{
|
||||
double sum = 0, sumSq = 0;
|
||||
int count = 0;
|
||||
foreach (var v in history)
|
||||
{
|
||||
if (v <= 0) continue;
|
||||
sum += v;
|
||||
sumSq += v * v;
|
||||
count++;
|
||||
}
|
||||
if (count == 0) return double.MaxValue;
|
||||
double mean = sum / count;
|
||||
double variance = sumSq / count - mean * mean;
|
||||
double stddev = variance > 0 ? Math.Sqrt(variance) : 0;
|
||||
return mean + 2 * stddev;
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
public class HeatMap : Panel
|
||||
{
|
||||
record Tick(byte[] Freq, byte[] Intensity);
|
||||
|
||||
readonly List<Tick> _history = new();
|
||||
readonly int _maxTicks;
|
||||
|
||||
public HeatMap(int maxTicks = 60)
|
||||
{
|
||||
_maxTicks = maxTicks;
|
||||
DoubleBuffered = true;
|
||||
BackColor = Color.FromArgb(20, 20, 22);
|
||||
}
|
||||
|
||||
public void AddTick(byte[] freq, byte[] intensity, bool active)
|
||||
{
|
||||
if (_history.Count >= _maxTicks)
|
||||
_history.RemoveAt(0);
|
||||
|
||||
if (active)
|
||||
_history.Add(new Tick(freq, intensity));
|
||||
else
|
||||
_history.Add(new Tick(Array.Empty<byte>(), Array.Empty<byte>()));
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = SmoothingMode.None;
|
||||
|
||||
int w = Width;
|
||||
int h = Height;
|
||||
|
||||
// Draw axis labels
|
||||
using var font = new Font(FontFamily.GenericMonospace, 7);
|
||||
using var textBrush = new SolidBrush(Color.FromArgb(100, 100, 100));
|
||||
g.DrawString("100Hz", font, textBrush, 2, 2);
|
||||
g.DrawString("1Hz", font, textBrush, 2, h - 14);
|
||||
|
||||
int plotX = 36;
|
||||
int plotW = w - plotX - 4;
|
||||
int plotH = h - 4;
|
||||
int colW = Math.Max(1, plotW / _maxTicks);
|
||||
|
||||
for (int i = 0; i < _history.Count; i++)
|
||||
{
|
||||
var tick = _history[i];
|
||||
int xPos = plotX + i * colW;
|
||||
|
||||
if (tick.Freq.Length == 0)
|
||||
{
|
||||
// Inactive — dark cell
|
||||
using var dark = new SolidBrush(Color.FromArgb(25, 25, 28));
|
||||
g.FillRectangle(dark, xPos, 2, colW, plotH);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4 sub-ticks side by side within the column
|
||||
int subW = Math.Max(1, colW / 4);
|
||||
for (int s = 0; s < 4; s++)
|
||||
{
|
||||
int freq = tick.Freq[s]; // 10-240
|
||||
int inten = tick.Intensity[s]; // 0-100
|
||||
if (inten > 100) inten = 0; // 101 sentinel = off
|
||||
|
||||
// Map freq byte (10-240) to Y position (top=high freq, bottom=low freq)
|
||||
float normFreq = (freq - 10f) / (240f - 10f);
|
||||
int y = (int)(2 + plotH * (1f - normFreq));
|
||||
if (y < 2) y = 2;
|
||||
if (y > plotH) y = plotH;
|
||||
|
||||
var color = HeatColor(inten);
|
||||
using var brush = new SolidBrush(color);
|
||||
g.FillRectangle(brush, xPos + s * subW, y, subW, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Color HeatColor(int intensity)
|
||||
{
|
||||
// 0=black, 30=blue, 60=green, 80=gold, 100=red
|
||||
if (intensity <= 0) return Color.FromArgb(20, 20, 22);
|
||||
if (intensity < 30)
|
||||
{
|
||||
float t = intensity / 30f;
|
||||
return Color.FromArgb(0, (int)(50 * t), (int)(80 + 80 * t));
|
||||
}
|
||||
if (intensity < 60)
|
||||
{
|
||||
float t = (intensity - 30) / 30f;
|
||||
return Color.FromArgb((int)(60 * t), (int)(130 + 80 * t), (int)(160 - 100 * t));
|
||||
}
|
||||
if (intensity < 80)
|
||||
{
|
||||
float t = (intensity - 60) / 20f;
|
||||
return Color.FromArgb((int)(60 + 180 * t), 210, (int)(60 - 30 * t));
|
||||
}
|
||||
float t2 = (intensity - 80) / 20f;
|
||||
return Color.FromArgb(255, (int)(240 - 140 * t2), (int)(30 - 10 * t2));
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
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<double> _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 { }
|
||||
}
|
||||
}
|
||||
+541
-222
@@ -1,7 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Diagnostics;
|
||||
using NAudio.Wave;
|
||||
using NAudio.CoreAudioApi;
|
||||
|
||||
namespace Substation;
|
||||
|
||||
@@ -14,35 +13,52 @@ public class MainForm : Form
|
||||
readonly NotifyIcon _tray;
|
||||
readonly Label _lblBle;
|
||||
readonly Label _lblWs;
|
||||
readonly Label _lblStrength;
|
||||
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 ProgressBar _gaugeA;
|
||||
readonly ProgressBar _gaugeB;
|
||||
readonly Label _lblGaugeA;
|
||||
readonly Label _lblGaugeB;
|
||||
readonly TrackBar _limitBar;
|
||||
readonly Label _lblLimit;
|
||||
readonly CheckBox _chkScale;
|
||||
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 TrackBar _ampBarA;
|
||||
readonly TrackBar _ampBarB;
|
||||
readonly Label _lblLimitA;
|
||||
readonly Label _lblLimitB;
|
||||
readonly Label _lblLimitValA;
|
||||
readonly Label _lblLimitValB;
|
||||
readonly Label _lblAmpA;
|
||||
readonly Label _lblAmpB;
|
||||
readonly Label _lblAmpValA;
|
||||
readonly Label _lblAmpValB;
|
||||
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 Button _btnRandom;
|
||||
readonly Label _lblTrack;
|
||||
bool _isMusicRunning;
|
||||
WaveOutEvent? _audioOut;
|
||||
Stopwatch? _musicStopwatch;
|
||||
readonly Label _lblAudioDev;
|
||||
readonly ComboBox _cboAudioDev;
|
||||
bool _isLiveRunning;
|
||||
bool _isPatternRunning;
|
||||
bool _isRandomRunning;
|
||||
LiveCapture? _liveCapture;
|
||||
|
||||
public MainForm(CoyoteDevice device, State state, Server server)
|
||||
{
|
||||
@@ -51,8 +67,8 @@ public class MainForm : Form
|
||||
_server = server;
|
||||
_loopCts = new CancellationTokenSource();
|
||||
|
||||
Text = "Substation";
|
||||
ClientSize = new Size(360, 340);
|
||||
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
|
||||
ClientSize = new Size(420, 520);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
@@ -62,7 +78,7 @@ public class MainForm : Form
|
||||
{
|
||||
Text = "BLE: searching...",
|
||||
Location = new Point(16, 16),
|
||||
Size = new Size(328, 20),
|
||||
Size = new Size(388, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
|
||||
@@ -70,129 +86,272 @@ public class MainForm : Form
|
||||
{
|
||||
Text = "WS: idle",
|
||||
Location = new Point(16, 40),
|
||||
Size = new Size(328, 20),
|
||||
Size = new Size(388, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
|
||||
_lblStrength = new Label
|
||||
{
|
||||
Text = "Strength: A=0 B=0",
|
||||
Location = new Point(16, 64),
|
||||
Size = new Size(328, 20)
|
||||
};
|
||||
|
||||
_lblDeviceName = new Label
|
||||
{
|
||||
Text = "Device:",
|
||||
Location = new Point(16, 90),
|
||||
Location = new Point(16, 66),
|
||||
Size = new Size(48, 20)
|
||||
};
|
||||
_txtDeviceName = new TextBox
|
||||
{
|
||||
Text = "47L121000",
|
||||
Location = new Point(68, 88),
|
||||
Location = new Point(68, 64),
|
||||
Size = new Size(160, 20)
|
||||
};
|
||||
|
||||
_btnConnect = new Button
|
||||
{
|
||||
Text = "Connect",
|
||||
Location = new Point(236, 88),
|
||||
Size = new Size(108, 24)
|
||||
Location = new Point(236, 64),
|
||||
Size = new Size(80, 24)
|
||||
};
|
||||
_btnConnect.Click += OnConnect;
|
||||
|
||||
_btnTest = new Button
|
||||
_chkSwap = new CheckBox
|
||||
{
|
||||
Text = "Test",
|
||||
Location = new Point(16, 120),
|
||||
Size = new Size(100, 32)
|
||||
Text = "Swap",
|
||||
Location = new Point(328, 66),
|
||||
Size = new Size(56, 20),
|
||||
Checked = false
|
||||
};
|
||||
_btnTest.Click += OnTest;
|
||||
_chkSwap.CheckedChanged += OnSwapChanged;
|
||||
|
||||
_btnMusic = new Button
|
||||
_btnLive = new Button
|
||||
{
|
||||
Text = "Music",
|
||||
Location = new Point(130, 120),
|
||||
Size = new Size(100, 32)
|
||||
Text = "Live",
|
||||
Location = new Point(16, 96),
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnMusic.Click += OnMusic;
|
||||
_btnLive.Click += OnLive;
|
||||
|
||||
_btnPattern = new Button
|
||||
{
|
||||
Text = "Pattern",
|
||||
Location = new Point(104, 96),
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnPattern.Click += OnPattern;
|
||||
|
||||
_btnRandom = new Button
|
||||
{
|
||||
Text = "Random",
|
||||
Location = new Point(192, 96),
|
||||
Size = new Size(80, 32)
|
||||
};
|
||||
_btnRandom.Click += OnRandom;
|
||||
|
||||
_btnStop = new Button
|
||||
{
|
||||
Text = "Stop All",
|
||||
Location = new Point(244, 120),
|
||||
Size = new Size(100, 32)
|
||||
Location = new Point(324, 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(328, 16),
|
||||
Size = new Size(388, 16),
|
||||
ForeColor = Color.DimGray
|
||||
};
|
||||
|
||||
_lblGaugeA = new Label
|
||||
_lblSendA = new Label
|
||||
{
|
||||
Text = "A",
|
||||
Location = new Point(16, 180),
|
||||
Size = new Size(16, 20),
|
||||
Text = "Send A",
|
||||
Location = new Point(16, 184),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_gaugeA = new ProgressBar
|
||||
_heatA = new HeatMap(60)
|
||||
{
|
||||
Location = new Point(40, 180),
|
||||
Size = new Size(304, 20),
|
||||
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
|
||||
};
|
||||
_lblGaugeB = new Label
|
||||
_lblRecvB = new Label
|
||||
{
|
||||
Text = "B",
|
||||
Location = new Point(16, 208),
|
||||
Size = new Size(16, 20),
|
||||
Text = "Recv B",
|
||||
Location = new Point(16, 266),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_gaugeB = new ProgressBar
|
||||
_gaugeRecvB = new ProgressBar
|
||||
{
|
||||
Location = new Point(40, 208),
|
||||
Size = new Size(304, 20),
|
||||
Location = new Point(72, 266),
|
||||
Size = new Size(332, 20),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Style = ProgressBarStyle.Continuous
|
||||
};
|
||||
|
||||
_lblLimit = new Label
|
||||
_lblLimitA = new Label
|
||||
{
|
||||
Text = "Limit: 30",
|
||||
Location = new Point(16, 248),
|
||||
Size = new Size(64, 20),
|
||||
Text = "Lim A",
|
||||
Location = new Point(16, 298),
|
||||
Size = new Size(40, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_limitBar = new TrackBar
|
||||
_limitBarA = new TrackBar
|
||||
{
|
||||
Location = new Point(80, 244),
|
||||
Size = new Size(180, 45),
|
||||
Location = new Point(60, 294),
|
||||
Size = new Size(220, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
TickFrequency = 50,
|
||||
Value = 30
|
||||
};
|
||||
_limitBar.ValueChanged += OnLimitChanged;
|
||||
_chkScale = new CheckBox
|
||||
_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(268, 248),
|
||||
Location = new Point(320, 298),
|
||||
Size = new Size(76, 24),
|
||||
Checked = false
|
||||
};
|
||||
_chkScale.CheckedChanged += OnLimitChanged;
|
||||
_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, _lblStrength, _lblDeviceName, _txtDeviceName, _btnConnect, _btnTest, _btnMusic, _btnStop, _lblTrack, _lblGaugeA, _gaugeA, _lblGaugeB, _gaugeB, _lblLimit, _limitBar, _chkScale });
|
||||
_lblAmpA = new Label
|
||||
{
|
||||
Text = "Amp A",
|
||||
Location = new Point(16, 394),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_ampBarA = new TrackBar
|
||||
{
|
||||
Location = new Point(68, 390),
|
||||
Size = new Size(212, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
TickFrequency = 25,
|
||||
Value = 0
|
||||
};
|
||||
_ampBarA.ValueChanged += OnAmpChanged;
|
||||
FixTrackbarKeys(_ampBarA);
|
||||
_lblAmpValA = new Label
|
||||
{
|
||||
Text = "0",
|
||||
Location = new Point(286, 394),
|
||||
Size = new Size(32, 20),
|
||||
TextAlign = ContentAlignment.MiddleRight
|
||||
};
|
||||
|
||||
_lblAmpB = new Label
|
||||
{
|
||||
Text = "Amp B",
|
||||
Location = new Point(16, 442),
|
||||
Size = new Size(48, 20),
|
||||
Font = new Font(Font.FontFamily, 9, FontStyle.Bold)
|
||||
};
|
||||
_ampBarB = new TrackBar
|
||||
{
|
||||
Location = new Point(68, 438),
|
||||
Size = new Size(212, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
TickFrequency = 25,
|
||||
Value = 0
|
||||
};
|
||||
_ampBarB.ValueChanged += OnAmpChanged;
|
||||
FixTrackbarKeys(_ampBarB);
|
||||
_lblAmpValB = new Label
|
||||
{
|
||||
Text = "0",
|
||||
Location = new Point(286, 442),
|
||||
Size = new Size(32, 20),
|
||||
TextAlign = ContentAlignment.MiddleRight
|
||||
};
|
||||
OnAmpChanged(null, EventArgs.Empty);
|
||||
|
||||
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnLive, _btnPattern, _btnRandom, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB, _lblAmpA, _ampBarA, _lblAmpValA, _lblAmpB, _ampBarB, _lblAmpValB });
|
||||
|
||||
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
||||
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
||||
@@ -211,7 +370,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();
|
||||
|
||||
@@ -220,6 +379,8 @@ public class MainForm : Form
|
||||
Resize += OnResize;
|
||||
|
||||
_server.ClientChanged += OnWsClientChanged;
|
||||
_device.ConnectionChanged += OnDeviceConnectionChanged;
|
||||
_device.StrengthChanged += OnDeviceStrengthChanged;
|
||||
}
|
||||
|
||||
async void OnLoad(object? sender, EventArgs e)
|
||||
@@ -242,12 +403,29 @@ public class MainForm : Form
|
||||
{
|
||||
try
|
||||
{
|
||||
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
|
||||
if (_device.IsConnected)
|
||||
{
|
||||
var (modeA, valA, modeB, valB, freqA, intA, freqB, intB) = _state.ConsumeTick();
|
||||
var frame = B0.Build(0, modeA, modeB, valA, valB, freqA, intA, freqB, intB);
|
||||
await _device.SendB0(frame);
|
||||
}
|
||||
if (!IsDisposed)
|
||||
{
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
double aScale = _state.LimitScaleA;
|
||||
double bScale = _state.LimitScaleB;
|
||||
var scaledIntA = new byte[4];
|
||||
var scaledIntB = new byte[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
scaledIntA[i] = (byte)Math.Round(intA[i] * aScale);
|
||||
scaledIntB[i] = (byte)Math.Round(intB[i] * bScale);
|
||||
}
|
||||
_heatA.AddTick(freqA, scaledIntA, _state.LastActiveA);
|
||||
_heatB.AddTick(freqB, scaledIntB, _state.LastActiveB);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -266,7 +444,6 @@ public class MainForm : Form
|
||||
|
||||
void ExitFromTray()
|
||||
{
|
||||
_closingFromTray = true;
|
||||
_tray.Visible = false;
|
||||
Application.Exit();
|
||||
}
|
||||
@@ -279,12 +456,6 @@ public class MainForm : Form
|
||||
|
||||
void OnFormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing && !_closingFromTray)
|
||||
{
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
_tray.Visible = false;
|
||||
_statusTimer.Stop();
|
||||
_loopCts.Cancel();
|
||||
@@ -319,95 +490,69 @@ 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 = _isLiveRunning || _isPatternRunning || _isRandomRunning;
|
||||
_btnLive.Enabled = !_server.HasClient && !busy;
|
||||
_btnPattern.Enabled = !_server.HasClient && !busy;
|
||||
_btnRandom.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";
|
||||
_lblStrength.Text = $"Strength: A={_device.StrengthA} B={_device.StrengthB}";
|
||||
_gaugeA.Value = Math.Clamp(_device.StrengthA, 0, 200);
|
||||
_gaugeB.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}";
|
||||
}
|
||||
else if (_isRandomRunning)
|
||||
{
|
||||
_lblTrack.Text = "Random noise";
|
||||
}
|
||||
|
||||
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;
|
||||
_btnTest.Enabled = !_server.HasClient;
|
||||
});
|
||||
}
|
||||
|
||||
void OnStop(object? sender, EventArgs e)
|
||||
{
|
||||
StopMusic();
|
||||
StopLive();
|
||||
StopPattern();
|
||||
StopRandom();
|
||||
_state.SetStrength('A', 0);
|
||||
_state.SetStrength('B', 0);
|
||||
_state.Stop('A');
|
||||
@@ -416,113 +561,270 @@ public class MainForm : Form
|
||||
|
||||
void OnLimitChanged(object? sender, EventArgs e)
|
||||
{
|
||||
var max = _limitBar.Value;
|
||||
var mode = _chkScale.Checked ? LimitMode.Scale : LimitMode.Clamp;
|
||||
_state.SetLimit(max, mode);
|
||||
_lblLimit.Text = $"Limit: {max}";
|
||||
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}";
|
||||
}
|
||||
|
||||
string _musicFilePath = "";
|
||||
TimeSpan _musicTotalTime;
|
||||
|
||||
void OnMusic(object? sender, EventArgs e)
|
||||
void OnAmpChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isMusicRunning || _server.HasClient) return;
|
||||
var ampA = 1.0 + _ampBarA.Value / 100.0 * 9.0;
|
||||
var ampB = 1.0 + _ampBarB.Value / 100.0 * 9.0;
|
||||
_state.SetAmpA(ampA);
|
||||
_state.SetAmpB(ampB);
|
||||
_lblAmpValA.Text = $"{_ampBarA.Value}";
|
||||
_lblAmpValB.Text = $"{_ampBarB.Value}";
|
||||
}
|
||||
|
||||
using var dlg = new OpenFileDialog
|
||||
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++)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CancellationTokenSource? _randomCts;
|
||||
|
||||
void OnRandom(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isRandomRunning || _server.HasClient) return;
|
||||
|
||||
_isRandomRunning = true;
|
||||
_btnRandom.Enabled = false;
|
||||
_lblTrack.Text = "Random noise";
|
||||
|
||||
_state.SetStrength('A', 60);
|
||||
_state.SetStrength('B', 60);
|
||||
_state.Stop('A');
|
||||
_state.Stop('B');
|
||||
|
||||
_randomCts = new CancellationTokenSource();
|
||||
Task.Run(() => RunRandomAsync(_randomCts.Token));
|
||||
}
|
||||
|
||||
async Task RunRandomAsync(CancellationToken ct)
|
||||
{
|
||||
// Pink noise via Voss-McCartney algorithm (1/f spectrum)
|
||||
// Separate state per channel, B inverted for counterphase
|
||||
var rng = new Random();
|
||||
int octaves = 8;
|
||||
var rowsA = new double[octaves];
|
||||
var rowsB = new double[octaves];
|
||||
double pinkA = 0, pinkB = 0;
|
||||
|
||||
while (!ct.IsCancellationRequested && _isRandomRunning)
|
||||
{
|
||||
// Channel A
|
||||
int updateA = rng.Next(octaves);
|
||||
rowsA[updateA] = rng.NextDouble() * 2 - 1;
|
||||
pinkA = 0;
|
||||
for (int i = 0; i < octaves; i++) pinkA += rowsA[i];
|
||||
pinkA /= octaves;
|
||||
|
||||
// Channel B (independent state, inverted for counterphase)
|
||||
int updateB = rng.Next(octaves);
|
||||
rowsB[updateB] = rng.NextDouble() * 2 - 1;
|
||||
pinkB = 0;
|
||||
for (int i = 0; i < octaves; i++) pinkB += rowsB[i];
|
||||
pinkB /= octaves;
|
||||
pinkB = -pinkB; // counterphase
|
||||
|
||||
// Map pink noise (-1..1) to intensity (0..80, capped for comfort)
|
||||
int intA = (int)Math.Clamp((pinkA + 1) * 40, 0, 80);
|
||||
int intB = (int)Math.Clamp((pinkB + 1) * 40, 0, 80);
|
||||
|
||||
// Random frequency: map pink noise to period (10-1000ms)
|
||||
int freqMsA = (int)Math.Clamp((pinkA + 1) * 500, 10, 1000);
|
||||
int freqMsB = (int)Math.Clamp((pinkB + 1) * 500, 10, 1000);
|
||||
|
||||
var frameA = new WaveFrame(
|
||||
Freq.Compress4(new[] { freqMsA, freqMsA, freqMsA, freqMsA }),
|
||||
Intensity.Clamp4(new[] { intA, intA, intA, intA }));
|
||||
var frameB = new WaveFrame(
|
||||
Freq.Compress4(new[] { freqMsB, freqMsB, freqMsB, freqMsB }),
|
||||
Intensity.Clamp4(new[] { intB, intB, intB, intB }));
|
||||
|
||||
_state.EnqueueStream('A', new[] { frameA });
|
||||
_state.EnqueueStream('B', new[] { frameB });
|
||||
|
||||
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
void StopRandom()
|
||||
{
|
||||
_randomCts?.Cancel();
|
||||
_randomCts = null;
|
||||
|
||||
if (_isRandomRunning)
|
||||
{
|
||||
_isRandomRunning = 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"
|
||||
@@ -538,6 +840,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;
|
||||
|
||||
+36
-170
@@ -1,157 +1,22 @@
|
||||
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 melodyEnergy, double melodyFreq)
|
||||
ExtractFeatures(double[] magnitude, 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;
|
||||
int rhythmLoBin = (int)(RhythmLow / binWidth);
|
||||
int rhythmHiBin = (int)(RhythmHigh / binWidth);
|
||||
double binWidth = (double)sampleRate / WindowSize;
|
||||
int melodyLoBin = (int)(MelodyLow / binWidth);
|
||||
int melodyHiBin = (int)(MelodyHigh / binWidth);
|
||||
|
||||
// Rhythm band energy
|
||||
double rhythmEnergy = 0;
|
||||
for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++)
|
||||
rhythmEnergy += magnitude[i] * magnitude[i];
|
||||
rhythmEnergy = Math.Sqrt(rhythmEnergy / (rhythmHiBin - rhythmLoBin + 1));
|
||||
|
||||
// Spectral flux (positive change in rhythm band)
|
||||
double rhythmFlux = 0;
|
||||
if (prevRhythmMag != null)
|
||||
{
|
||||
for (int i = rhythmLoBin; i <= rhythmHiBin && i < magnitude.Length; i++)
|
||||
{
|
||||
double diff = magnitude[i] - prevRhythmMag[i];
|
||||
if (diff > 0) rhythmFlux += diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Melody band: energy + dominant frequency (spectral peak)
|
||||
double melodyEnergy = 0;
|
||||
double peakMag = 0;
|
||||
@@ -169,10 +34,10 @@ public static class MusicAnalyzer
|
||||
melodyEnergy = Math.Sqrt(melodyEnergy / (melodyHiBin - melodyLoBin + 1));
|
||||
double melodyFreq = peakBin * binWidth;
|
||||
|
||||
return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq);
|
||||
return (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 +50,37 @@ public static class MusicAnalyzer
|
||||
return Math.Clamp(ms, 10, 1000);
|
||||
}
|
||||
|
||||
static double[] DecodeToMono(string mp3Path)
|
||||
public class TickFeature
|
||||
{
|
||||
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 double RhythmFlux;
|
||||
public double MelodyEnergy;
|
||||
public double MelodyCount;
|
||||
public readonly List<double> MelodyFreqSamples = new();
|
||||
}
|
||||
|
||||
public static WaveFrame BuildMelodyFrame(TickFeature tf, double maxMelodyEnergy)
|
||||
{
|
||||
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(freqB, intB);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.5–10): 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.5–10): Same division for channel B. Controls the "pause" duration.
|
||||
- **Ramp** (0.5–5): Applies a smoothness filter to transitions. Higher = slower ramps.
|
||||
- **Min Freq** (0–100): Low cutoff on frequency values. No idea why.
|
||||
- **Min Level** (0–100): Low cutoff on intensity values. Floor that's held during "pause".
|
||||
|
||||
### Frequency mapping
|
||||
|
||||
xToys frequency is 0–100 (percentage). Mapped to e-stim period:
|
||||
- 0% = 1000ms (1Hz, deep thump)
|
||||
- 100% = 10ms (100Hz, buzzy)
|
||||
|
||||
Formula: `period_ms = 1000 - 9.9 * freq_pct`
|
||||
|
||||
@@ -13,12 +13,6 @@ static class Program
|
||||
var state = new State();
|
||||
var server = new Server(device, state, Port);
|
||||
|
||||
device.StrengthChanged += (a, b) =>
|
||||
{
|
||||
state.ActualA = a;
|
||||
state.ActualB = b;
|
||||
};
|
||||
|
||||
Application.Run(new MainForm(device, state, server));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ public class Command
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
[JsonPropertyName("mode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Value { get; set; }
|
||||
|
||||
@@ -242,12 +242,7 @@ public class Server
|
||||
|
||||
string DoStatus()
|
||||
{
|
||||
return JsonSerializer.Serialize(new StatusResponse
|
||||
{
|
||||
Connected = _device.IsConnected,
|
||||
StrengthA = _device.StrengthA,
|
||||
StrengthB = _device.StrengthB
|
||||
}, JsonOpts);
|
||||
return BuildStatusPush(null);
|
||||
}
|
||||
|
||||
string DoStrength(Command cmd)
|
||||
|
||||
@@ -15,18 +15,52 @@ public class State
|
||||
public readonly ConcurrentQueue<WaveFrame> StreamA = new();
|
||||
public readonly ConcurrentQueue<WaveFrame> StreamB = new();
|
||||
|
||||
public int ActualA, ActualB;
|
||||
public double LimitScaleA, LimitScaleB;
|
||||
public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB;
|
||||
public bool LastActiveA, LastActiveB;
|
||||
|
||||
public int MaxStrength = 200;
|
||||
public LimitMode LimitMode = LimitMode.Clamp;
|
||||
public int MaxA = 200;
|
||||
public int MaxB = 200;
|
||||
public LimitMode LimitModeA = LimitMode.Clamp;
|
||||
public LimitMode LimitModeB = LimitMode.Clamp;
|
||||
public bool SwapChannels;
|
||||
public double AmpA = 1.0;
|
||||
public double AmpB = 1.0;
|
||||
|
||||
public void SetLimit(int max, LimitMode mode)
|
||||
public void SetLimitA(int max, LimitMode mode)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
MaxStrength = Math.Clamp(max, 0, 200);
|
||||
LimitMode = mode;
|
||||
MaxA = Math.Clamp(max, 0, 200);
|
||||
LimitModeA = mode;
|
||||
DirtyA = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLimitB(int max, LimitMode mode)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
MaxB = Math.Clamp(max, 0, 200);
|
||||
LimitModeB = mode;
|
||||
DirtyB = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetAmpA(double amp)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
AmpA = Math.Clamp(amp, 0.1, 10.0);
|
||||
DirtyA = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetAmpB(double amp)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
AmpB = Math.Clamp(amp, 0.1, 10.0);
|
||||
DirtyB = true;
|
||||
}
|
||||
}
|
||||
@@ -91,19 +125,49 @@ public class State
|
||||
var modeA = DirtyA ? StrengthMode.Abs : StrengthMode.None;
|
||||
var modeB = DirtyB ? StrengthMode.Abs : StrengthMode.None;
|
||||
|
||||
var limit = MaxStrength;
|
||||
var limMode = LimitMode;
|
||||
byte ApplyLimit(int desired) =>
|
||||
limMode == LimitMode.Scale
|
||||
? (byte)(desired * limit / 200)
|
||||
: (byte)Math.Clamp(desired, 0, limit);
|
||||
var valA = ApplyLimit(DesiredA);
|
||||
var valB = ApplyLimit(DesiredB);
|
||||
byte ApplyLimit(int desired, int max, LimitMode mode) =>
|
||||
mode == LimitMode.Scale
|
||||
? (byte)(desired * max / 200)
|
||||
: (byte)Math.Clamp(desired, 0, max);
|
||||
var valA = ApplyLimit(DesiredA, MaxA, LimitModeA);
|
||||
var valB = ApplyLimit(DesiredB, MaxB, LimitModeB);
|
||||
LimitScaleA = DesiredA > 0 ? (double)valA / DesiredA : 0;
|
||||
LimitScaleB = DesiredB > 0 ? (double)valB / DesiredB : 0;
|
||||
DirtyA = false;
|
||||
DirtyB = false;
|
||||
|
||||
var (freqA, intA) = PopWave(StreamA, LoopFreqA, LoopIntA);
|
||||
var (freqB, intB) = PopWave(StreamB, LoopFreqB, LoopIntB);
|
||||
bool hasA = !StreamA.IsEmpty || (LoopFreqA != null && LoopIntA != null);
|
||||
bool hasB = !StreamB.IsEmpty || (LoopFreqB != null && LoopIntB != null);
|
||||
var (freqA, intARaw) = PopWave(StreamA, LoopFreqA, LoopIntA);
|
||||
var (freqB, intBRaw) = PopWave(StreamB, LoopFreqB, LoopIntB);
|
||||
|
||||
// Apply amplifier to intensity, clamp to 0-100
|
||||
var ampA = AmpA;
|
||||
var ampB = AmpB;
|
||||
var intA = new byte[4];
|
||||
var intB = new byte[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
intA[i] = (byte)Math.Clamp((int)Math.Round(intARaw[i] * ampA), 0, 100);
|
||||
intB[i] = (byte)Math.Clamp((int)Math.Round(intBRaw[i] * ampB), 0, 100);
|
||||
}
|
||||
|
||||
LastActiveA = hasA;
|
||||
LastActiveB = hasB;
|
||||
LastFreqA = freqA; LastIntA = intA;
|
||||
LastFreqB = freqB; LastIntB = intB;
|
||||
|
||||
if (SwapChannels)
|
||||
{
|
||||
(valA, valB) = (valB, valA);
|
||||
(modeA, modeB) = (modeB, modeA);
|
||||
(freqA, freqB) = (freqB, freqA);
|
||||
(intA, intB) = (intB, intA);
|
||||
(LastFreqA, LastFreqB) = (LastFreqB, LastFreqA);
|
||||
(LastIntA, LastIntB) = (LastIntB, LastIntA);
|
||||
(LastActiveA, LastActiveB) = (LastActiveB, LastActiveA);
|
||||
(LimitScaleA, LimitScaleB) = (LimitScaleB, LimitScaleA);
|
||||
}
|
||||
|
||||
return (modeA, valA, modeB, valB, freqA, intA, freqB, intB);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AssemblyName>Substation</AssemblyName>
|
||||
<RootNamespace>Substation</RootNamespace>
|
||||
<Version>0.3.0</Version>
|
||||
<AssemblyVersion>0.3.0</AssemblyVersion>
|
||||
<FileVersion>0.3.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
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)
|
||||
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 patternType = pattern.GetProperty("type").GetString() ?? "script-v3";
|
||||
var patternData = pattern.GetProperty("patternData");
|
||||
|
||||
return patternType switch
|
||||
{
|
||||
"basic-v2" => ParseBasicV2(name, patternData),
|
||||
"funscript" => ParseFunscript(name, patternData),
|
||||
"draw" => ParseFunscript(name, patternData),
|
||||
"audio" => ParseAudio(name, pattern, patternData),
|
||||
"script-v3" => ParseScriptV3(name, patternData),
|
||||
_ => ParseScriptV3(name, patternData)
|
||||
};
|
||||
}
|
||||
|
||||
// ── script-v3: slider-driven, arbitrary pattern names, initialActions channel mapping ──
|
||||
|
||||
static XToysPattern ParseScriptV3(string name, JsonElement patternData)
|
||||
{
|
||||
var sliders = ResolveSliders(patternData);
|
||||
bool hasFreq = patternData.TryGetProperty("frequencyControl", out var fc) && fc.GetBoolean();
|
||||
int channelCount = patternData.TryGetProperty("channels", out var cc) ? cc.GetInt32() : 2;
|
||||
|
||||
// Parse initialActions to map pattern names → channels
|
||||
var patternToChannel = new Dictionary<string, int>();
|
||||
if (patternData.TryGetProperty("initialActions", out var actions))
|
||||
{
|
||||
foreach (var action in actions.EnumerateArray())
|
||||
{
|
||||
if (action.TryGetProperty("type", out var t) && t.GetString() == "updatePattern")
|
||||
{
|
||||
var patName = action.GetProperty("pattern").GetString()!;
|
||||
var channels = action.GetProperty("channels");
|
||||
int ch = 1;
|
||||
if (channels.TryGetProperty("1", out var c1) && c1.GetBoolean()) ch = 1;
|
||||
else if (channels.TryGetProperty("2", out var c2) && c2.GetBoolean()) ch = 2;
|
||||
patternToChannel[patName] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all pattern names
|
||||
var patterns = patternData.GetProperty("patterns");
|
||||
var patternNames = new List<string>();
|
||||
foreach (var prop in patterns.EnumerateObject())
|
||||
patternNames.Add(prop.Name);
|
||||
|
||||
// Classify patterns: frequency (name contains "freq") vs intensity
|
||||
var intPatterns = new List<(string name, int channel)>();
|
||||
var freqPatterns = new List<(string name, int channel)>();
|
||||
|
||||
foreach (var pname in patternNames)
|
||||
{
|
||||
int ch = patternToChannel.GetValueOrDefault(pname, 1);
|
||||
if (hasFreq && pname.Contains("freq", StringComparison.OrdinalIgnoreCase))
|
||||
freqPatterns.Add((pname, ch));
|
||||
else
|
||||
intPatterns.Add((pname, ch));
|
||||
}
|
||||
|
||||
// Evaluate patterns
|
||||
var intByChannel = new Dictionary<int, byte[]>();
|
||||
var freqByChannel = new Dictionary<int, byte[]>();
|
||||
|
||||
foreach (var (pname, ch) in intPatterns)
|
||||
intByChannel[ch] = EvaluateLoops(patterns.GetProperty(pname), sliders);
|
||||
|
||||
foreach (var (pname, ch) in freqPatterns)
|
||||
freqByChannel[ch] = EvaluateLoops(patterns.GetProperty(pname), sliders);
|
||||
|
||||
// Get arrays for each channel (default to empty)
|
||||
var intA = intByChannel.GetValueOrDefault(1, Array.Empty<byte>());
|
||||
var intB = channelCount >= 2
|
||||
? intByChannel.GetValueOrDefault(2, intA)
|
||||
: intA; // single channel → duplicate
|
||||
var freqA = freqByChannel.GetValueOrDefault(1, Array.Empty<byte>());
|
||||
var freqB = channelCount >= 2
|
||||
? freqByChannel.GetValueOrDefault(2, freqA)
|
||||
: freqA;
|
||||
|
||||
int tickCount = Math.Max(intA.Length, Math.Max(intB.Length, Math.Max(freqA.Length, freqB.Length)));
|
||||
if (tickCount == 0) tickCount = 1;
|
||||
|
||||
byte defaultFreq = Freq.Compress(150);
|
||||
var channelA = new List<WaveFrame>(tickCount);
|
||||
var channelB = new List<WaveFrame>(tickCount);
|
||||
|
||||
for (int i = 0; i < tickCount; i++)
|
||||
{
|
||||
byte ia = i < intA.Length ? intA[i] : (byte)0;
|
||||
byte ib = i < intB.Length ? intB[i] : (byte)0;
|
||||
byte fa = i < freqA.Length ? Freq.Compress(FreqPctToMs(freqA[i])) : defaultFreq;
|
||||
byte fb = i < freqB.Length ? Freq.Compress(FreqPctToMs(freqB[i])) : defaultFreq;
|
||||
|
||||
channelA.Add(new WaveFrame(new[] { fa, fa, fa, fa }, new[] { ia, ia, ia, ia }));
|
||||
channelB.Add(new WaveFrame(new[] { fb, fb, fb, fb }, new[] { ib, ib, ib, ib }));
|
||||
}
|
||||
|
||||
return new XToysPattern(name, channelA, channelB);
|
||||
}
|
||||
|
||||
// ── basic-v2: channelData with loops/steps, optional frequencyControl ──
|
||||
|
||||
static XToysPattern ParseBasicV2(string name, JsonElement patternData)
|
||||
{
|
||||
var emptySliders = new Dictionary<string, double>();
|
||||
const int defaultFreqMs = 150;
|
||||
bool hasFreq = patternData.TryGetProperty("frequencyControl", out var fc) && fc.GetBoolean();
|
||||
|
||||
byte freqByte = Freq.Compress(defaultFreqMs);
|
||||
|
||||
var intA = Array.Empty<byte>();
|
||||
var intB = Array.Empty<byte>();
|
||||
var freqA = Array.Empty<byte>();
|
||||
var freqB = Array.Empty<byte>();
|
||||
|
||||
if (patternData.TryGetProperty("channelData", out var cd))
|
||||
{
|
||||
if (cd.TryGetProperty("1", out var ch1))
|
||||
{
|
||||
intA = EvaluateLoops(ch1, emptySliders);
|
||||
if (hasFreq) freqA = intA; // basic-v2 with freq: same data is freq
|
||||
}
|
||||
if (cd.TryGetProperty("2", out var ch2))
|
||||
{
|
||||
intB = EvaluateLoops(ch2, emptySliders);
|
||||
if (hasFreq) freqB = intB;
|
||||
}
|
||||
}
|
||||
|
||||
// Single channel → duplicate
|
||||
if (intB.Length == 0 && intA.Length > 0)
|
||||
{
|
||||
intB = intA;
|
||||
freqB = freqA;
|
||||
}
|
||||
if (intA.Length == 0 && intB.Length > 0)
|
||||
{
|
||||
intA = intB;
|
||||
freqA = freqB;
|
||||
}
|
||||
|
||||
int tickCount = Math.Max(intA.Length, intB.Length);
|
||||
if (tickCount == 0) tickCount = 1;
|
||||
|
||||
var channelA = new List<WaveFrame>(tickCount);
|
||||
var channelB = new List<WaveFrame>(tickCount);
|
||||
|
||||
for (int i = 0; i < tickCount; i++)
|
||||
{
|
||||
byte ia = i < intA.Length ? intA[i] : (byte)0;
|
||||
byte ib = i < intB.Length ? intB[i] : (byte)0;
|
||||
byte fa = hasFreq && i < freqA.Length ? Freq.Compress(FreqPctToMs(freqA[i])) : freqByte;
|
||||
byte fb = hasFreq && i < freqB.Length ? Freq.Compress(FreqPctToMs(freqB[i])) : freqByte;
|
||||
|
||||
channelA.Add(new WaveFrame(new[] { fa, fa, fa, fa }, new[] { ia, ia, ia, ia }));
|
||||
channelB.Add(new WaveFrame(new[] { fb, fb, fb, fb }, new[] { ib, ib, ib, ib }));
|
||||
}
|
||||
|
||||
return new XToysPattern(name, channelA, channelB);
|
||||
}
|
||||
|
||||
// ── funscript / draw: {at, pos} timeline, resample to 100ms ticks ──
|
||||
|
||||
static XToysPattern ParseFunscript(string name, JsonElement patternData)
|
||||
{
|
||||
byte freqByte = Freq.Compress(150);
|
||||
|
||||
var intA = Array.Empty<byte>();
|
||||
var intB = Array.Empty<byte>();
|
||||
|
||||
if (patternData.TryGetProperty("channelData", out var cd))
|
||||
{
|
||||
if (cd.TryGetProperty("1", out var ch1))
|
||||
intA = ResampleTimeline(ch1);
|
||||
if (cd.TryGetProperty("2", out var ch2))
|
||||
intB = ResampleTimeline(ch2);
|
||||
}
|
||||
|
||||
// Single channel → duplicate
|
||||
if (intB.Length == 0 && intA.Length > 0) intB = intA;
|
||||
if (intA.Length == 0 && intB.Length > 0) intA = intB;
|
||||
|
||||
int tickCount = Math.Max(intA.Length, intB.Length);
|
||||
if (tickCount == 0) tickCount = 1;
|
||||
|
||||
var channelA = new List<WaveFrame>(tickCount);
|
||||
var channelB = new List<WaveFrame>(tickCount);
|
||||
|
||||
for (int i = 0; i < tickCount; i++)
|
||||
{
|
||||
byte ia = i < intA.Length ? intA[i] : (byte)0;
|
||||
byte ib = i < intB.Length ? intB[i] : (byte)0;
|
||||
|
||||
channelA.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ia, ia, ia, ia }));
|
||||
channelB.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ib, ib, ib, ib }));
|
||||
}
|
||||
|
||||
return new XToysPattern(name, channelA, channelB);
|
||||
}
|
||||
|
||||
static byte[] ResampleTimeline(JsonElement channelData)
|
||||
{
|
||||
// channelData is an array of {at, pos} objects (at in ms, pos 0-100)
|
||||
var points = new List<(double at, double pos)>();
|
||||
foreach (var pt in channelData.EnumerateArray())
|
||||
{
|
||||
double at = pt.GetProperty("at").GetDouble();
|
||||
double pos = pt.GetProperty("pos").GetDouble();
|
||||
points.Add((at, pos));
|
||||
}
|
||||
|
||||
if (points.Count == 0) return Array.Empty<byte>();
|
||||
if (points.Count == 1) return new[] { (byte)Math.Clamp((int)Math.Round(points[0].pos), 0, 100) };
|
||||
|
||||
double durationMs = points[^1].at;
|
||||
int tickCount = Math.Max(1, (int)Math.Ceiling(durationMs / 100.0));
|
||||
|
||||
var result = new byte[tickCount];
|
||||
int ptIdx = 0;
|
||||
|
||||
for (int t = 0; t < tickCount; t++)
|
||||
{
|
||||
double tickMs = t * 100.0;
|
||||
|
||||
// Advance to the right pair of points
|
||||
while (ptIdx < points.Count - 2 && points[ptIdx + 1].at < tickMs)
|
||||
ptIdx++;
|
||||
|
||||
// Linear interpolation between points[ptIdx] and points[ptIdx + 1]
|
||||
double pos;
|
||||
if (points[ptIdx + 1].at == points[ptIdx].at)
|
||||
pos = points[ptIdx].pos;
|
||||
else
|
||||
{
|
||||
double frac = (tickMs - points[ptIdx].at) / (points[ptIdx + 1].at - points[ptIdx].at);
|
||||
frac = Math.Clamp(frac, 0, 1);
|
||||
pos = points[ptIdx].pos + (points[ptIdx + 1].pos - points[ptIdx].pos) * frac;
|
||||
}
|
||||
|
||||
result[t] = (byte)Math.Clamp((int)Math.Round(pos), 0, 100);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── audio: flat float array, resample to 100ms ticks ──
|
||||
|
||||
static XToysPattern ParseAudio(string name, JsonElement pattern, JsonElement patternData)
|
||||
{
|
||||
byte freqByte = Freq.Compress(150);
|
||||
|
||||
// Length is in seconds (from pattern.length or patternData.length)
|
||||
double lengthSec = 0;
|
||||
if (pattern.TryGetProperty("length", out var lenEl) && lenEl.ValueKind == JsonValueKind.Number)
|
||||
lengthSec = lenEl.GetDouble();
|
||||
else if (pattern.TryGetProperty("length", out var lenStr) && lenStr.ValueKind == JsonValueKind.String)
|
||||
double.TryParse(lenStr.GetString(), out lengthSec);
|
||||
|
||||
double scale = patternData.TryGetProperty("scale", out var scaleEl) ? scaleEl.GetDouble() : 100;
|
||||
|
||||
var intA = Array.Empty<byte>();
|
||||
var intB = Array.Empty<byte>();
|
||||
|
||||
if (patternData.TryGetProperty("channelData", out var cd))
|
||||
{
|
||||
if (cd.TryGetProperty("1", out var ch1))
|
||||
intA = ResampleAudio(ch1, scale, lengthSec);
|
||||
if (cd.TryGetProperty("2", out var ch2))
|
||||
intB = ResampleAudio(ch2, scale, lengthSec);
|
||||
}
|
||||
|
||||
// Single channel → duplicate
|
||||
if (intB.Length == 0 && intA.Length > 0) intB = intA;
|
||||
if (intA.Length == 0 && intB.Length > 0) intA = intB;
|
||||
|
||||
int tickCount = Math.Max(intA.Length, intB.Length);
|
||||
if (tickCount == 0) tickCount = 1;
|
||||
|
||||
var channelA = new List<WaveFrame>(tickCount);
|
||||
var channelB = new List<WaveFrame>(tickCount);
|
||||
|
||||
for (int i = 0; i < tickCount; i++)
|
||||
{
|
||||
byte ia = i < intA.Length ? intA[i] : (byte)0;
|
||||
byte ib = i < intB.Length ? intB[i] : (byte)0;
|
||||
|
||||
channelA.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ia, ia, ia, ia }));
|
||||
channelB.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ib, ib, ib, ib }));
|
||||
}
|
||||
|
||||
return new XToysPattern(name, channelA, channelB);
|
||||
}
|
||||
|
||||
static byte[] ResampleAudio(JsonElement channelData, double scale, double lengthSec)
|
||||
{
|
||||
// channelData is a flat array of float values (raw, need to divide by scale)
|
||||
var samples = new List<double>();
|
||||
foreach (var s in channelData.EnumerateArray())
|
||||
samples.Add(s.GetDouble() / scale * 100.0);
|
||||
|
||||
if (samples.Count == 0) return Array.Empty<byte>();
|
||||
|
||||
// Determine tick count from length (seconds → 100ms ticks)
|
||||
int tickCount;
|
||||
if (lengthSec > 0)
|
||||
tickCount = Math.Max(1, (int)Math.Ceiling(lengthSec * 10));
|
||||
else
|
||||
tickCount = samples.Count; // fallback: 1 sample per tick
|
||||
|
||||
var result = new byte[tickCount];
|
||||
|
||||
for (int t = 0; t < tickCount; t++)
|
||||
{
|
||||
// Map tick to sample index
|
||||
int idx = (int)Math.Round((double)t * samples.Count / tickCount);
|
||||
if (idx >= samples.Count) idx = samples.Count - 1;
|
||||
result[t] = (byte)Math.Clamp((int)Math.Round(samples[idx]), 0, 100);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Shared helpers ──
|
||||
|
||||
static Dictionary<string, double> ResolveSliders(JsonElement patternData)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
return sliders;
|
||||
}
|
||||
|
||||
static byte[] EvaluateLoops(JsonElement loopsElement, Dictionary<string, double> sliders)
|
||||
{
|
||||
var values = new List<byte>();
|
||||
double lastEnd = 0;
|
||||
|
||||
foreach (var loop in loopsElement.EnumerateArray())
|
||||
{
|
||||
var steps = loop.GetProperty("steps");
|
||||
foreach (var step in steps.EnumerateArray())
|
||||
{
|
||||
var type = step.GetProperty("type").GetString() ?? "straight";
|
||||
var time = EvaluateExpression(GetStepValue(step, "time"), sliders);
|
||||
var startVal = step.TryGetProperty("start", out var s)
|
||||
? (s.ValueKind == JsonValueKind.Null ? lastEnd : EvaluateExpression(GetStepValue(step, "start"), sliders))
|
||||
: lastEnd;
|
||||
var endVal = EvaluateExpression(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(CultureInfo.InvariantCulture),
|
||||
_ => "0"
|
||||
};
|
||||
}
|
||||
|
||||
static double EvaluateExpression(string expr, Dictionary<string, double> sliders)
|
||||
{
|
||||
// Replace {var} references with slider values
|
||||
var resolved = Regex.Replace(expr, @"\{(\w+)\}", m =>
|
||||
sliders.TryGetValue(m.Groups[1].Value, out var val)
|
||||
? val.ToString(CultureInfo.InvariantCulture)
|
||||
: "0");
|
||||
|
||||
// Try plain number first
|
||||
if (double.TryParse(resolved, CultureInfo.InvariantCulture, out var simple))
|
||||
return simple;
|
||||
|
||||
// Pre-process pow(a, b) calls — DataTable.Compute doesn't support pow
|
||||
resolved = Regex.Replace(resolved, @"pow\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)", m =>
|
||||
{
|
||||
double a = double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
double b = double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
return Math.Pow(a, b).ToString("G15", CultureInfo.InvariantCulture);
|
||||
});
|
||||
|
||||
// Evaluate with DataTable.Compute (handles +, -, *, /, parentheses)
|
||||
try
|
||||
{
|
||||
var result = new DataTable().Compute(resolved, null);
|
||||
return Convert.ToDouble(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try simple number
|
||||
return double.TryParse(resolved, CultureInfo.InvariantCulture, out var fallback) ? fallback : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user