Compare commits
9 Commits
abb5364bb4
...
mistress
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce20544a4 | |||
| 496cc5a6b2 | |||
| 1ab397767a | |||
| badc599dee | |||
| 5efdd63212 | |||
| f1e7d976ca | |||
| 4e36580b22 | |||
| 4ad85f57ec | |||
| 4c44d48a02 |
+1
-1
@@ -33,7 +33,7 @@ public class CoyoteDevice : IDisposable
|
|||||||
selector = BluetoothLEDevice.GetDeviceSelectorFromPairingState(false);
|
selector = BluetoothLEDevice.GetDeviceSelectorFromPairingState(false);
|
||||||
devices = await DeviceInformation.FindAllAsync(selector);
|
devices = await DeviceInformation.FindAllAsync(selector);
|
||||||
var match = devices.FirstOrDefault(d =>
|
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("DG-LAB", StringComparison.OrdinalIgnoreCase) ||
|
||||||
d.Name.Contains("Coyote", StringComparison.OrdinalIgnoreCase));
|
d.Name.Contains("Coyote", StringComparison.OrdinalIgnoreCase));
|
||||||
if (match == null)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -37,7 +37,6 @@ public class HeatMap : Panel
|
|||||||
|
|
||||||
int w = Width;
|
int w = Width;
|
||||||
int h = Height;
|
int h = Height;
|
||||||
int colW = Math.Max(1, w / _maxTicks);
|
|
||||||
|
|
||||||
// Draw axis labels
|
// Draw axis labels
|
||||||
using var font = new Font(FontFamily.GenericMonospace, 7);
|
using var font = new Font(FontFamily.GenericMonospace, 7);
|
||||||
@@ -48,7 +47,7 @@ public class HeatMap : Panel
|
|||||||
int plotX = 36;
|
int plotX = 36;
|
||||||
int plotW = w - plotX - 4;
|
int plotW = w - plotX - 4;
|
||||||
int plotH = h - 4;
|
int plotH = h - 4;
|
||||||
colW = Math.Max(1, plotW / _maxTicks);
|
int colW = Math.Max(1, plotW / _maxTicks);
|
||||||
|
|
||||||
for (int i = 0; i < _history.Count; i++)
|
for (int i = 0; i < _history.Count; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
+58
-11
@@ -1,7 +1,7 @@
|
|||||||
using System.Numerics;
|
|
||||||
using FftSharp;
|
using FftSharp;
|
||||||
using NAudio.CoreAudioApi;
|
using NAudio.CoreAudioApi;
|
||||||
using NAudio.Wave;
|
using NAudio.Wave;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
namespace Substation;
|
namespace Substation;
|
||||||
|
|
||||||
@@ -16,12 +16,28 @@ public class LiveCapture : IDisposable
|
|||||||
readonly Queue<double> _sampleBuffer = new();
|
readonly Queue<double> _sampleBuffer = new();
|
||||||
readonly object _bufferLock = new();
|
readonly object _bufferLock = new();
|
||||||
|
|
||||||
double[]? _prevRhythmMag;
|
|
||||||
double _liveMaxFlux;
|
|
||||||
double _liveMaxMelodyEnergy;
|
double _liveMaxMelodyEnergy;
|
||||||
int _sampleRate;
|
int _sampleRate;
|
||||||
int _fftsPerTick;
|
int _fftsPerTick;
|
||||||
int _fftIndexInTick;
|
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 event Action? Stopped;
|
||||||
|
|
||||||
@@ -38,6 +54,12 @@ public class LiveCapture : IDisposable
|
|||||||
_sampleRate = _capture.WaveFormat.SampleRate;
|
_sampleRate = _capture.WaveFormat.SampleRate;
|
||||||
_fftsPerTick = Math.Max(1, (int)Math.Round(MusicAnalyzer.TickDuration * _sampleRate / MusicAnalyzer.HopSize));
|
_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.DataAvailable += OnDataAvailable;
|
||||||
_capture.RecordingStopped += OnRecordingStopped;
|
_capture.RecordingStopped += OnRecordingStopped;
|
||||||
|
|
||||||
@@ -45,7 +67,8 @@ public class LiveCapture : IDisposable
|
|||||||
_processThread.Start();
|
_processThread.Start();
|
||||||
|
|
||||||
_capture.StartRecording();
|
_capture.StartRecording();
|
||||||
Console.WriteLine($"[live] capture started: {_device.FriendlyName} ({_sampleRate}Hz, {_capture.WaveFormat.Channels}ch)");
|
Log($"[live] capture started");
|
||||||
|
Log($"[live] log file: {LogPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
@@ -61,6 +84,12 @@ public class LiveCapture : IDisposable
|
|||||||
int frameSize = channels * bytesPerSample;
|
int frameSize = channels * bytesPerSample;
|
||||||
int sampleCount = e.BytesRecorded / frameSize;
|
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)
|
lock (_bufferLock)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < sampleCount; i++)
|
for (int i = 0; i < sampleCount; i++)
|
||||||
@@ -83,7 +112,9 @@ public class LiveCapture : IDisposable
|
|||||||
{
|
{
|
||||||
_running = false;
|
_running = false;
|
||||||
Stopped?.Invoke();
|
Stopped?.Invoke();
|
||||||
Console.WriteLine("[live] capture stopped");
|
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()
|
void ProcessLoop()
|
||||||
@@ -93,6 +124,8 @@ public class LiveCapture : IDisposable
|
|||||||
var tf = new MusicAnalyzer.TickFeature();
|
var tf = new MusicAnalyzer.TickFeature();
|
||||||
double[]? overlap = null; // last HopSize samples from previous window
|
double[]? overlap = null; // last HopSize samples from previous window
|
||||||
|
|
||||||
|
Log("[live] process thread started");
|
||||||
|
|
||||||
while (_running)
|
while (_running)
|
||||||
{
|
{
|
||||||
double[]? windowData = null;
|
double[]? windowData = null;
|
||||||
@@ -122,6 +155,8 @@ public class LiveCapture : IDisposable
|
|||||||
|
|
||||||
if (windowData == null)
|
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);
|
Thread.Sleep(5);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -130,16 +165,19 @@ public class LiveCapture : IDisposable
|
|||||||
var spectrum = FFT.Forward(windowData);
|
var spectrum = FFT.Forward(windowData);
|
||||||
var mag = FFT.Magnitude(spectrum);
|
var mag = FFT.Magnitude(spectrum);
|
||||||
|
|
||||||
var (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) =
|
_fftCount++;
|
||||||
MusicAnalyzer.ExtractFeatures(mag, _prevRhythmMag, _sampleRate);
|
|
||||||
|
|
||||||
_prevRhythmMag = mag;
|
var (melodyEnergy, melodyFreq) = MusicAnalyzer.ExtractFeatures(mag, _sampleRate);
|
||||||
|
|
||||||
// Adaptive normalization: running max with slow decay
|
// Adaptive normalization: running max with slow decay
|
||||||
_liveMaxFlux = Math.Max(rhythmFlux, _liveMaxFlux * 0.999);
|
|
||||||
_liveMaxMelodyEnergy = Math.Max(melodyEnergy, _liveMaxMelodyEnergy * 0.999);
|
_liveMaxMelodyEnergy = Math.Max(melodyEnergy, _liveMaxMelodyEnergy * 0.999);
|
||||||
|
|
||||||
tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux);
|
// 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.MelodyEnergy += melodyEnergy;
|
||||||
tf.MelodyFreqSamples.Add(melodyFreq);
|
tf.MelodyFreqSamples.Add(melodyFreq);
|
||||||
tf.MelodyCount++;
|
tf.MelodyCount++;
|
||||||
@@ -147,10 +185,19 @@ public class LiveCapture : IDisposable
|
|||||||
|
|
||||||
if (_fftIndexInTick >= _fftsPerTick)
|
if (_fftIndexInTick >= _fftsPerTick)
|
||||||
{
|
{
|
||||||
var (frameA, frameB) = MusicAnalyzer.BuildWaveFrame(tf, _liveMaxFlux, _liveMaxMelodyEnergy);
|
var frameA = _drums.BuildFrame();
|
||||||
|
var frameB = MusicAnalyzer.BuildMelodyFrame(tf, _liveMaxMelodyEnergy);
|
||||||
_state.EnqueueStream('A', new[] { frameA });
|
_state.EnqueueStream('A', new[] { frameA });
|
||||||
_state.EnqueueStream('B', new[] { frameB });
|
_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();
|
tf = new MusicAnalyzer.TickFeature();
|
||||||
_fftIndexInTick = 0;
|
_fftIndexInTick = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-84
@@ -16,13 +16,10 @@ public class MainForm : Form
|
|||||||
readonly Label _lblDeviceName;
|
readonly Label _lblDeviceName;
|
||||||
readonly TextBox _txtDeviceName;
|
readonly TextBox _txtDeviceName;
|
||||||
readonly Button _btnConnect;
|
readonly Button _btnConnect;
|
||||||
readonly Button _btnTest;
|
|
||||||
readonly Button _btnStop;
|
readonly Button _btnStop;
|
||||||
readonly System.Windows.Forms.Timer _statusTimer;
|
readonly System.Windows.Forms.Timer _statusTimer;
|
||||||
readonly CancellationTokenSource _loopCts;
|
readonly CancellationTokenSource _loopCts;
|
||||||
|
|
||||||
bool _closingFromTray;
|
|
||||||
|
|
||||||
readonly HeatMap _heatA;
|
readonly HeatMap _heatA;
|
||||||
readonly HeatMap _heatB;
|
readonly HeatMap _heatB;
|
||||||
readonly ProgressBar _gaugeRecvA;
|
readonly ProgressBar _gaugeRecvA;
|
||||||
@@ -33,10 +30,16 @@ public class MainForm : Form
|
|||||||
readonly Label _lblRecvB;
|
readonly Label _lblRecvB;
|
||||||
readonly TrackBar _limitBarA;
|
readonly TrackBar _limitBarA;
|
||||||
readonly TrackBar _limitBarB;
|
readonly TrackBar _limitBarB;
|
||||||
|
readonly TrackBar _ampBarA;
|
||||||
|
readonly TrackBar _ampBarB;
|
||||||
readonly Label _lblLimitA;
|
readonly Label _lblLimitA;
|
||||||
readonly Label _lblLimitB;
|
readonly Label _lblLimitB;
|
||||||
readonly Label _lblLimitValA;
|
readonly Label _lblLimitValA;
|
||||||
readonly Label _lblLimitValB;
|
readonly Label _lblLimitValB;
|
||||||
|
readonly Label _lblAmpA;
|
||||||
|
readonly Label _lblAmpB;
|
||||||
|
readonly Label _lblAmpValA;
|
||||||
|
readonly Label _lblAmpValB;
|
||||||
readonly CheckBox _chkScaleA;
|
readonly CheckBox _chkScaleA;
|
||||||
readonly CheckBox _chkScaleB;
|
readonly CheckBox _chkScaleB;
|
||||||
readonly CheckBox _chkSwap;
|
readonly CheckBox _chkSwap;
|
||||||
@@ -48,11 +51,13 @@ public class MainForm : Form
|
|||||||
|
|
||||||
readonly Button _btnLive;
|
readonly Button _btnLive;
|
||||||
readonly Button _btnPattern;
|
readonly Button _btnPattern;
|
||||||
|
readonly Button _btnRandom;
|
||||||
readonly Label _lblTrack;
|
readonly Label _lblTrack;
|
||||||
readonly Label _lblAudioDev;
|
readonly Label _lblAudioDev;
|
||||||
readonly ComboBox _cboAudioDev;
|
readonly ComboBox _cboAudioDev;
|
||||||
bool _isLiveRunning;
|
bool _isLiveRunning;
|
||||||
bool _isPatternRunning;
|
bool _isPatternRunning;
|
||||||
|
bool _isRandomRunning;
|
||||||
LiveCapture? _liveCapture;
|
LiveCapture? _liveCapture;
|
||||||
|
|
||||||
public MainForm(CoyoteDevice device, State state, Server server)
|
public MainForm(CoyoteDevice device, State state, Server server)
|
||||||
@@ -63,7 +68,7 @@ public class MainForm : Form
|
|||||||
_loopCts = new CancellationTokenSource();
|
_loopCts = new CancellationTokenSource();
|
||||||
|
|
||||||
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
|
Text = $"Substation {typeof(MainForm).Assembly.GetName().Version}";
|
||||||
ClientSize = new Size(420, 450);
|
ClientSize = new Size(420, 520);
|
||||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
@@ -115,18 +120,10 @@ public class MainForm : Form
|
|||||||
};
|
};
|
||||||
_chkSwap.CheckedChanged += OnSwapChanged;
|
_chkSwap.CheckedChanged += OnSwapChanged;
|
||||||
|
|
||||||
_btnTest = new Button
|
|
||||||
{
|
|
||||||
Text = "Test",
|
|
||||||
Location = new Point(16, 96),
|
|
||||||
Size = new Size(80, 32)
|
|
||||||
};
|
|
||||||
_btnTest.Click += OnTest;
|
|
||||||
|
|
||||||
_btnLive = new Button
|
_btnLive = new Button
|
||||||
{
|
{
|
||||||
Text = "Live",
|
Text = "Live",
|
||||||
Location = new Point(104, 96),
|
Location = new Point(16, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnLive.Click += OnLive;
|
_btnLive.Click += OnLive;
|
||||||
@@ -134,15 +131,23 @@ public class MainForm : Form
|
|||||||
_btnPattern = new Button
|
_btnPattern = new Button
|
||||||
{
|
{
|
||||||
Text = "Pattern",
|
Text = "Pattern",
|
||||||
Location = new Point(192, 96),
|
Location = new Point(104, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnPattern.Click += OnPattern;
|
_btnPattern.Click += OnPattern;
|
||||||
|
|
||||||
|
_btnRandom = new Button
|
||||||
|
{
|
||||||
|
Text = "Random",
|
||||||
|
Location = new Point(192, 96),
|
||||||
|
Size = new Size(80, 32)
|
||||||
|
};
|
||||||
|
_btnRandom.Click += OnRandom;
|
||||||
|
|
||||||
_btnStop = new Button
|
_btnStop = new Button
|
||||||
{
|
{
|
||||||
Text = "Stop All",
|
Text = "Stop All",
|
||||||
Location = new Point(280, 96),
|
Location = new Point(324, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnStop.Click += OnStop;
|
_btnStop.Click += OnStop;
|
||||||
@@ -293,7 +298,60 @@ public class MainForm : Form
|
|||||||
_chkScaleB.CheckedChanged += OnLimitChanged;
|
_chkScaleB.CheckedChanged += OnLimitChanged;
|
||||||
OnLimitChanged(null, EventArgs.Empty);
|
OnLimitChanged(null, EventArgs.Empty);
|
||||||
|
|
||||||
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnLive, _btnPattern, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
_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
|
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
||||||
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
||||||
@@ -355,8 +413,17 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
BeginInvoke(() =>
|
BeginInvoke(() =>
|
||||||
{
|
{
|
||||||
_heatA.AddTick(freqA, intA, _state.LastActiveA);
|
double aScale = _state.LimitScaleA;
|
||||||
_heatB.AddTick(freqB, intB, _state.LastActiveB);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +444,6 @@ public class MainForm : Form
|
|||||||
|
|
||||||
void ExitFromTray()
|
void ExitFromTray()
|
||||||
{
|
{
|
||||||
_closingFromTray = true;
|
|
||||||
_tray.Visible = false;
|
_tray.Visible = false;
|
||||||
Application.Exit();
|
Application.Exit();
|
||||||
}
|
}
|
||||||
@@ -390,12 +456,6 @@ public class MainForm : Form
|
|||||||
|
|
||||||
void OnFormClosing(object? sender, FormClosingEventArgs e)
|
void OnFormClosing(object? sender, FormClosingEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.CloseReason == CloseReason.UserClosing && !_closingFromTray)
|
|
||||||
{
|
|
||||||
e.Cancel = true;
|
|
||||||
Hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_tray.Visible = false;
|
_tray.Visible = false;
|
||||||
_statusTimer.Stop();
|
_statusTimer.Stop();
|
||||||
_loopCts.Cancel();
|
_loopCts.Cancel();
|
||||||
@@ -461,10 +521,10 @@ public class MainForm : Form
|
|||||||
|
|
||||||
void RefreshButtonStates()
|
void RefreshButtonStates()
|
||||||
{
|
{
|
||||||
bool busy = IsTestRunning || _isLiveRunning || _isPatternRunning;
|
bool busy = _isLiveRunning || _isPatternRunning || _isRandomRunning;
|
||||||
_btnTest.Enabled = !_server.HasClient && !busy;
|
|
||||||
_btnLive.Enabled = !_server.HasClient && !busy;
|
_btnLive.Enabled = !_server.HasClient && !busy;
|
||||||
_btnPattern.Enabled = !_server.HasClient && !busy;
|
_btnPattern.Enabled = !_server.HasClient && !busy;
|
||||||
|
_btnRandom.Enabled = !_server.HasClient && !busy;
|
||||||
_btnStop.Enabled = true;
|
_btnStop.Enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,72 +540,19 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
_lblTrack.Text = $"Pattern: {_patternName}";
|
_lblTrack.Text = $"Pattern: {_patternName}";
|
||||||
}
|
}
|
||||||
|
else if (_isRandomRunning)
|
||||||
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;
|
_lblTrack.Text = "Random noise";
|
||||||
// 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');
|
UpdateTrayIcon();
|
||||||
_state.EnqueueStream('A', framesA);
|
|
||||||
_state.EnqueueStream('B', framesB);
|
|
||||||
|
|
||||||
// Wait for the stream to be consumed (~3s) plus margin, then silence.
|
|
||||||
await Task.Delay(3300);
|
|
||||||
|
|
||||||
_state.SetStrength('A', 0);
|
|
||||||
_state.SetStrength('B', 0);
|
|
||||||
_state.Stop('A');
|
|
||||||
_state.Stop('B');
|
|
||||||
|
|
||||||
BeginInvoke(() =>
|
|
||||||
{
|
|
||||||
IsTestRunning = false;
|
|
||||||
RefreshButtonStates();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnStop(object? sender, EventArgs e)
|
void OnStop(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
StopLive();
|
StopLive();
|
||||||
StopPattern();
|
StopPattern();
|
||||||
|
StopRandom();
|
||||||
_state.SetStrength('A', 0);
|
_state.SetStrength('A', 0);
|
||||||
_state.SetStrength('B', 0);
|
_state.SetStrength('B', 0);
|
||||||
_state.Stop('A');
|
_state.Stop('A');
|
||||||
@@ -564,6 +571,16 @@ public class MainForm : Form
|
|||||||
_lblLimitValB.Text = $"{maxB}";
|
_lblLimitValB.Text = $"{maxB}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OnAmpChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
void OnSwapChanged(object? sender, EventArgs e)
|
void OnSwapChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_state.SwapChannels = _chkSwap.Checked;
|
_state.SwapChannels = _chkSwap.Checked;
|
||||||
@@ -703,6 +720,90 @@ public class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
string ShowInputDialog(string prompt, string title)
|
||||||
{
|
{
|
||||||
using var dlg = new Form
|
using var dlg = new Form
|
||||||
|
|||||||
+5
-40
@@ -1,4 +1,3 @@
|
|||||||
using System.Numerics;
|
|
||||||
using FftSharp;
|
using FftSharp;
|
||||||
|
|
||||||
namespace Substation;
|
namespace Substation;
|
||||||
@@ -9,35 +8,15 @@ public static class MusicAnalyzer
|
|||||||
public const int HopSize = 1024;
|
public const int HopSize = 1024;
|
||||||
public const double TickDuration = 0.1;
|
public const double TickDuration = 0.1;
|
||||||
|
|
||||||
const double RhythmLow = 20, RhythmHigh = 250;
|
|
||||||
const double MelodyLow = 300, MelodyHigh = 4000;
|
const double MelodyLow = 300, MelodyHigh = 4000;
|
||||||
|
|
||||||
public static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq)
|
public static (double melodyEnergy, double melodyFreq)
|
||||||
ExtractFeatures(double[] magnitude, double[]? prevRhythmMag, int sampleRate)
|
ExtractFeatures(double[] magnitude, int sampleRate)
|
||||||
{
|
{
|
||||||
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);
|
int melodyLoBin = (int)(MelodyLow / binWidth);
|
||||||
int melodyHiBin = (int)(MelodyHigh / 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)
|
// Melody band: energy + dominant frequency (spectral peak)
|
||||||
double melodyEnergy = 0;
|
double melodyEnergy = 0;
|
||||||
double peakMag = 0;
|
double peakMag = 0;
|
||||||
@@ -55,7 +34,7 @@ public static class MusicAnalyzer
|
|||||||
melodyEnergy = Math.Sqrt(melodyEnergy / (melodyHiBin - melodyLoBin + 1));
|
melodyEnergy = Math.Sqrt(melodyEnergy / (melodyHiBin - melodyLoBin + 1));
|
||||||
double melodyFreq = peakBin * binWidth;
|
double melodyFreq = peakBin * binWidth;
|
||||||
|
|
||||||
return (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq);
|
return (melodyEnergy, melodyFreq);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int MapPitchToPeriod(double hz)
|
public static int MapPitchToPeriod(double hz)
|
||||||
@@ -73,27 +52,13 @@ public static class MusicAnalyzer
|
|||||||
|
|
||||||
public class TickFeature
|
public class TickFeature
|
||||||
{
|
{
|
||||||
public double RhythmFlux;
|
|
||||||
public double MelodyEnergy;
|
public double MelodyEnergy;
|
||||||
public double MelodyCount;
|
public double MelodyCount;
|
||||||
public readonly List<double> MelodyFreqSamples = new();
|
public readonly List<double> MelodyFreqSamples = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static (WaveFrame chA, WaveFrame chB) BuildWaveFrame(TickFeature tf, double maxFlux, double maxMelodyEnergy)
|
public static WaveFrame BuildMelodyFrame(TickFeature tf, 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 avgEnergy = tf.MelodyCount > 0 ? tf.MelodyEnergy / tf.MelodyCount : 0;
|
||||||
double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
|
double normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
|
||||||
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80);
|
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80);
|
||||||
@@ -116,6 +81,6 @@ public static class MusicAnalyzer
|
|||||||
};
|
};
|
||||||
var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs });
|
var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs });
|
||||||
|
|
||||||
return (new WaveFrame(freqA, intA), new WaveFrame(freqB, intB));
|
return new WaveFrame(freqB, intB);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ static class Program
|
|||||||
var state = new State();
|
var state = new State();
|
||||||
var server = new Server(device, state, Port);
|
var server = new Server(device, state, Port);
|
||||||
|
|
||||||
device.StrengthChanged += (a, b) =>
|
|
||||||
{
|
|
||||||
state.ActualA = a;
|
|
||||||
state.ActualB = b;
|
|
||||||
};
|
|
||||||
|
|
||||||
Application.Run(new MainForm(device, state, server));
|
Application.Run(new MainForm(device, state, server));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,6 @@ public class Command
|
|||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Channel { get; set; }
|
public string? Channel { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("mode")]
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
||||||
public string? Mode { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("value")]
|
[JsonPropertyName("value")]
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public int? Value { get; set; }
|
public int? Value { get; set; }
|
||||||
|
|||||||
@@ -242,12 +242,7 @@ public class Server
|
|||||||
|
|
||||||
string DoStatus()
|
string DoStatus()
|
||||||
{
|
{
|
||||||
return JsonSerializer.Serialize(new StatusResponse
|
return BuildStatusPush(null);
|
||||||
{
|
|
||||||
Connected = _device.IsConnected,
|
|
||||||
StrengthA = _device.StrengthA,
|
|
||||||
StrengthB = _device.StrengthB
|
|
||||||
}, JsonOpts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
string DoStrength(Command cmd)
|
string DoStrength(Command cmd)
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ public class State
|
|||||||
public readonly ConcurrentQueue<WaveFrame> StreamA = new();
|
public readonly ConcurrentQueue<WaveFrame> StreamA = new();
|
||||||
public readonly ConcurrentQueue<WaveFrame> StreamB = new();
|
public readonly ConcurrentQueue<WaveFrame> StreamB = new();
|
||||||
|
|
||||||
public int ActualA, ActualB;
|
public double LimitScaleA, LimitScaleB;
|
||||||
public int LastSentA, LastSentB;
|
|
||||||
public int LastIntensityA, LastIntensityB;
|
|
||||||
public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB;
|
public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB;
|
||||||
public bool LastActiveA, LastActiveB;
|
public bool LastActiveA, LastActiveB;
|
||||||
|
|
||||||
@@ -26,6 +24,8 @@ public class State
|
|||||||
public LimitMode LimitModeA = LimitMode.Clamp;
|
public LimitMode LimitModeA = LimitMode.Clamp;
|
||||||
public LimitMode LimitModeB = LimitMode.Clamp;
|
public LimitMode LimitModeB = LimitMode.Clamp;
|
||||||
public bool SwapChannels;
|
public bool SwapChannels;
|
||||||
|
public double AmpA = 1.0;
|
||||||
|
public double AmpB = 1.0;
|
||||||
|
|
||||||
public void SetLimitA(int max, LimitMode mode)
|
public void SetLimitA(int max, LimitMode mode)
|
||||||
{
|
{
|
||||||
@@ -47,6 +47,24 @@ public class State
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsSignaling
|
public bool IsSignaling
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -113,22 +131,31 @@ public class State
|
|||||||
: (byte)Math.Clamp(desired, 0, max);
|
: (byte)Math.Clamp(desired, 0, max);
|
||||||
var valA = ApplyLimit(DesiredA, MaxA, LimitModeA);
|
var valA = ApplyLimit(DesiredA, MaxA, LimitModeA);
|
||||||
var valB = ApplyLimit(DesiredB, MaxB, LimitModeB);
|
var valB = ApplyLimit(DesiredB, MaxB, LimitModeB);
|
||||||
LastSentA = valA;
|
LimitScaleA = DesiredA > 0 ? (double)valA / DesiredA : 0;
|
||||||
LastSentB = valB;
|
LimitScaleB = DesiredB > 0 ? (double)valB / DesiredB : 0;
|
||||||
DirtyA = false;
|
DirtyA = false;
|
||||||
DirtyB = false;
|
DirtyB = false;
|
||||||
|
|
||||||
bool hasA = !StreamA.IsEmpty || (LoopFreqA != null && LoopIntA != null);
|
bool hasA = !StreamA.IsEmpty || (LoopFreqA != null && LoopIntA != null);
|
||||||
bool hasB = !StreamB.IsEmpty || (LoopFreqB != null && LoopIntB != null);
|
bool hasB = !StreamB.IsEmpty || (LoopFreqB != null && LoopIntB != null);
|
||||||
var (freqA, intA) = PopWave(StreamA, LoopFreqA, LoopIntA);
|
var (freqA, intARaw) = PopWave(StreamA, LoopFreqA, LoopIntA);
|
||||||
var (freqB, intB) = PopWave(StreamB, LoopFreqB, LoopIntB);
|
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;
|
LastActiveA = hasA;
|
||||||
LastActiveB = hasB;
|
LastActiveB = hasB;
|
||||||
LastFreqA = freqA; LastIntA = intA;
|
LastFreqA = freqA; LastIntA = intA;
|
||||||
LastFreqB = freqB; LastIntB = intB;
|
LastFreqB = freqB; LastIntB = intB;
|
||||||
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)
|
if (SwapChannels)
|
||||||
{
|
{
|
||||||
@@ -136,11 +163,10 @@ public class State
|
|||||||
(modeA, modeB) = (modeB, modeA);
|
(modeA, modeB) = (modeB, modeA);
|
||||||
(freqA, freqB) = (freqB, freqA);
|
(freqA, freqB) = (freqB, freqA);
|
||||||
(intA, intB) = (intB, intA);
|
(intA, intB) = (intB, intA);
|
||||||
(LastSentA, LastSentB) = (LastSentB, LastSentA);
|
|
||||||
(LastFreqA, LastFreqB) = (LastFreqB, LastFreqA);
|
(LastFreqA, LastFreqB) = (LastFreqB, LastFreqA);
|
||||||
(LastIntA, LastIntB) = (LastIntB, LastIntA);
|
(LastIntA, LastIntB) = (LastIntB, LastIntA);
|
||||||
(LastActiveA, LastActiveB) = (LastActiveB, LastActiveA);
|
(LastActiveA, LastActiveB) = (LastActiveB, LastActiveA);
|
||||||
(LastIntensityA, LastIntensityB) = (LastIntensityB, LastIntensityA);
|
(LimitScaleA, LimitScaleB) = (LimitScaleB, LimitScaleA);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (modeA, valA, modeB, valB, freqA, intA, freqB, intB);
|
return (modeA, valA, modeB, valB, freqA, intA, freqB, intB);
|
||||||
|
|||||||
+3
-3
@@ -11,9 +11,9 @@
|
|||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<AssemblyName>Substation</AssemblyName>
|
<AssemblyName>Substation</AssemblyName>
|
||||||
<RootNamespace>Substation</RootNamespace>
|
<RootNamespace>Substation</RootNamespace>
|
||||||
<Version>0.2.0</Version>
|
<Version>0.3.0</Version>
|
||||||
<AssemblyVersion>0.2.0</AssemblyVersion>
|
<AssemblyVersion>0.3.0</AssemblyVersion>
|
||||||
<FileVersion>0.2.0</FileVersion>
|
<FileVersion>0.3.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+361
-53
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -29,7 +31,6 @@ public static class XToysPatternImporter
|
|||||||
static int FreqPctToMs(byte freqPct)
|
static int FreqPctToMs(byte freqPct)
|
||||||
{
|
{
|
||||||
// xToys frequency: 0% = deep (1000ms), 100% = buzzy (10ms)
|
// 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);
|
return (int)Math.Round(1000 - 9.9 * freqPct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +46,332 @@ public static class XToysPatternImporter
|
|||||||
|
|
||||||
var pattern = doc.RootElement.GetProperty("result").GetProperty("pattern");
|
var pattern = doc.RootElement.GetProperty("result").GetProperty("pattern");
|
||||||
var name = pattern.GetProperty("name").GetString() ?? "Unknown";
|
var name = pattern.GetProperty("name").GetString() ?? "Unknown";
|
||||||
|
var patternType = pattern.GetProperty("type").GetString() ?? "script-v3";
|
||||||
var patternData = pattern.GetProperty("patternData");
|
var patternData = pattern.GetProperty("patternData");
|
||||||
|
|
||||||
// Resolve slider defaults
|
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>();
|
var sliders = new Dictionary<string, double>();
|
||||||
if (patternData.TryGetProperty("ma", out var ma))
|
if (patternData.TryGetProperty("ma", out var ma))
|
||||||
{
|
{
|
||||||
@@ -58,61 +382,25 @@ public static class XToysPatternImporter
|
|||||||
sliders[key] = value;
|
sliders[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return sliders;
|
||||||
// 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)
|
static byte[] EvaluateLoops(JsonElement loopsElement, 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>();
|
var values = new List<byte>();
|
||||||
double lastEnd = 0;
|
double lastEnd = 0;
|
||||||
|
|
||||||
foreach (var loop in patternArr.EnumerateArray())
|
foreach (var loop in loopsElement.EnumerateArray())
|
||||||
{
|
{
|
||||||
var steps = loop.GetProperty("steps");
|
var steps = loop.GetProperty("steps");
|
||||||
foreach (var step in steps.EnumerateArray())
|
foreach (var step in steps.EnumerateArray())
|
||||||
{
|
{
|
||||||
var type = step.GetProperty("type").GetString() ?? "straight";
|
var type = step.GetProperty("type").GetString() ?? "straight";
|
||||||
var time = ResolveExpr(GetStepValue(step, "time"), sliders);
|
var time = EvaluateExpression(GetStepValue(step, "time"), sliders);
|
||||||
var startVal = step.TryGetProperty("start", out var s)
|
var startVal = step.TryGetProperty("start", out var s)
|
||||||
? (s.ValueKind == JsonValueKind.Null ? lastEnd : ResolveExpr(GetStepValue(step, "start"), sliders))
|
? (s.ValueKind == JsonValueKind.Null ? lastEnd : EvaluateExpression(GetStepValue(step, "start"), sliders))
|
||||||
: lastEnd;
|
: lastEnd;
|
||||||
var endVal = ResolveExpr(GetStepValue(step, "end"), sliders);
|
var endVal = EvaluateExpression(GetStepValue(step, "end"), sliders);
|
||||||
|
|
||||||
int ticks = Math.Max(1, (int)Math.Round(time / 0.1));
|
int ticks = Math.Max(1, (int)Math.Round(time / 0.1));
|
||||||
|
|
||||||
@@ -142,21 +430,41 @@ public static class XToysPatternImporter
|
|||||||
return el.ValueKind switch
|
return el.ValueKind switch
|
||||||
{
|
{
|
||||||
JsonValueKind.String => el.GetString() ?? "0",
|
JsonValueKind.String => el.GetString() ?? "0",
|
||||||
JsonValueKind.Number => el.GetDouble().ToString(),
|
JsonValueKind.Number => el.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||||
_ => "0"
|
_ => "0"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static double ResolveExpr(string expr, Dictionary<string, double> sliders)
|
static double EvaluateExpression(string expr, Dictionary<string, double> sliders)
|
||||||
{
|
{
|
||||||
// Replace {var} references, then evaluate simple expressions like "ramp/2"
|
// Replace {var} references with slider values
|
||||||
var resolved = Regex.Replace(expr, @"\{(\w+)\}", m =>
|
var resolved = Regex.Replace(expr, @"\{(\w+)\}", m =>
|
||||||
sliders.TryGetValue(m.Groups[1].Value, out var val) ? val.ToString() : "0");
|
sliders.TryGetValue(m.Groups[1].Value, out var val)
|
||||||
|
? val.ToString(CultureInfo.InvariantCulture)
|
||||||
|
: "0");
|
||||||
|
|
||||||
// Parse simple arithmetic: number, or number/number
|
// Try plain number first
|
||||||
var parts = resolved.Split('/');
|
if (double.TryParse(resolved, CultureInfo.InvariantCulture, out var simple))
|
||||||
if (parts.Length == 2 && double.TryParse(parts[0], out var a) && double.TryParse(parts[1], out var b))
|
return simple;
|
||||||
return a / b;
|
|
||||||
return double.TryParse(resolved, out var result) ? result : 0;
|
// 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