Add xToys pattern import via URL paste
- XToysPattern.cs: fetch pattern from xtoys.app API (one call), parse script-v3 JSON, resolve slider defaults, evaluate sine/straight steps to WaveFrames at 100ms resolution - Frequency mapping: 0%=1000ms deep, 100%=10ms buzzy - MainForm: Pattern button + input dialog, continuously enqueues pattern frames as a loop, Stop All cancels - Fix: handle JSON values that are numbers vs strings (end/start fields) - NOTES.md: xToys slider parameter intel + frequency mapping docs
This commit is contained in:
+124
-11
@@ -50,11 +50,13 @@ public class MainForm : Form
|
|||||||
|
|
||||||
readonly Button _btnMusic;
|
readonly Button _btnMusic;
|
||||||
readonly Button _btnLive;
|
readonly Button _btnLive;
|
||||||
|
readonly Button _btnPattern;
|
||||||
readonly Label _lblTrack;
|
readonly Label _lblTrack;
|
||||||
readonly Label _lblAudioDev;
|
readonly Label _lblAudioDev;
|
||||||
readonly ComboBox _cboAudioDev;
|
readonly ComboBox _cboAudioDev;
|
||||||
bool _isMusicRunning;
|
bool _isMusicRunning;
|
||||||
bool _isLiveRunning;
|
bool _isLiveRunning;
|
||||||
|
bool _isPatternRunning;
|
||||||
WaveOutEvent? _audioOut;
|
WaveOutEvent? _audioOut;
|
||||||
Stopwatch? _musicStopwatch;
|
Stopwatch? _musicStopwatch;
|
||||||
LiveCapture? _liveCapture;
|
LiveCapture? _liveCapture;
|
||||||
@@ -123,31 +125,39 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
Text = "Test",
|
Text = "Test",
|
||||||
Location = new Point(16, 96),
|
Location = new Point(16, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(68, 32)
|
||||||
};
|
};
|
||||||
_btnTest.Click += OnTest;
|
_btnTest.Click += OnTest;
|
||||||
|
|
||||||
_btnMusic = new Button
|
_btnMusic = new Button
|
||||||
{
|
{
|
||||||
Text = "Music",
|
Text = "Music",
|
||||||
Location = new Point(104, 96),
|
Location = new Point(92, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(68, 32)
|
||||||
};
|
};
|
||||||
_btnMusic.Click += OnMusic;
|
_btnMusic.Click += OnMusic;
|
||||||
|
|
||||||
_btnLive = new Button
|
_btnLive = new Button
|
||||||
{
|
{
|
||||||
Text = "Live",
|
Text = "Live",
|
||||||
Location = new Point(192, 96),
|
Location = new Point(168, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(68, 32)
|
||||||
};
|
};
|
||||||
_btnLive.Click += OnLive;
|
_btnLive.Click += OnLive;
|
||||||
|
|
||||||
|
_btnPattern = new Button
|
||||||
|
{
|
||||||
|
Text = "Pattern",
|
||||||
|
Location = new Point(244, 96),
|
||||||
|
Size = new Size(68, 32)
|
||||||
|
};
|
||||||
|
_btnPattern.Click += OnPattern;
|
||||||
|
|
||||||
_btnStop = new Button
|
_btnStop = new Button
|
||||||
{
|
{
|
||||||
Text = "Stop All",
|
Text = "Stop All",
|
||||||
Location = new Point(280, 96),
|
Location = new Point(320, 96),
|
||||||
Size = new Size(80, 32)
|
Size = new Size(76, 32)
|
||||||
};
|
};
|
||||||
_btnStop.Click += OnStop;
|
_btnStop.Click += OnStop;
|
||||||
|
|
||||||
@@ -297,7 +307,7 @@ 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, _btnMusic, _btnLive, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnMusic, _btnLive, _btnPattern, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
||||||
|
|
||||||
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
||||||
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
||||||
@@ -465,9 +475,11 @@ public class MainForm : Form
|
|||||||
|
|
||||||
void RefreshButtonStates()
|
void RefreshButtonStates()
|
||||||
{
|
{
|
||||||
_btnTest.Enabled = !_server.HasClient && !IsTestRunning && !_isMusicRunning && !_isLiveRunning;
|
bool busy = IsTestRunning || _isMusicRunning || _isLiveRunning || _isPatternRunning;
|
||||||
_btnMusic.Enabled = !_server.HasClient && !_isMusicRunning && !IsTestRunning && !_isLiveRunning;
|
_btnTest.Enabled = !_server.HasClient && !busy;
|
||||||
_btnLive.Enabled = !_server.HasClient && !_isLiveRunning && !IsTestRunning && !_isMusicRunning;
|
_btnMusic.Enabled = !_server.HasClient && !busy;
|
||||||
|
_btnLive.Enabled = !_server.HasClient && !busy;
|
||||||
|
_btnPattern.Enabled = !_server.HasClient && !busy;
|
||||||
_btnStop.Enabled = true;
|
_btnStop.Enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +496,10 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
_lblTrack.Text = "Live capture";
|
_lblTrack.Text = "Live capture";
|
||||||
}
|
}
|
||||||
|
else if (_isPatternRunning)
|
||||||
|
{
|
||||||
|
_lblTrack.Text = $"Pattern: {_patternName}";
|
||||||
|
}
|
||||||
|
|
||||||
UpdateTrayIcon();
|
UpdateTrayIcon();
|
||||||
}
|
}
|
||||||
@@ -550,6 +566,7 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
StopMusic();
|
StopMusic();
|
||||||
StopLive();
|
StopLive();
|
||||||
|
StopPattern();
|
||||||
_state.SetStrength('A', 0);
|
_state.SetStrength('A', 0);
|
||||||
_state.SetStrength('B', 0);
|
_state.SetStrength('B', 0);
|
||||||
_state.Stop('A');
|
_state.Stop('A');
|
||||||
@@ -732,6 +749,102 @@ public class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string _patternName = "";
|
||||||
|
CancellationTokenSource? _patternCts;
|
||||||
|
|
||||||
|
void OnPattern(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_isPatternRunning || _server.HasClient) return;
|
||||||
|
|
||||||
|
var url = ShowInputDialog("Paste xToys pattern URL:", "Pattern Import");
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return;
|
||||||
|
|
||||||
|
_isPatternRunning = true;
|
||||||
|
_btnPattern.Enabled = false;
|
||||||
|
_lblTrack.Text = "Loading pattern...";
|
||||||
|
|
||||||
|
_patternCts = new CancellationTokenSource();
|
||||||
|
Task.Run(() => RunPatternAsync(url, _patternCts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RunPatternAsync(string url, CancellationToken ct)
|
||||||
|
{
|
||||||
|
XToysPattern? pattern = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pattern = await XToysPatternImporter.ImportAsync(url);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
_lblTrack.Text = $"Pattern failed: {ex.Message?.Split('\n')[0]}";
|
||||||
|
_isPatternRunning = false;
|
||||||
|
RefreshButtonStates();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_patternName = pattern.Name;
|
||||||
|
|
||||||
|
_state.SetStrength('A', 60);
|
||||||
|
_state.SetStrength('B', 60);
|
||||||
|
_state.Stop('A');
|
||||||
|
_state.Stop('B');
|
||||||
|
|
||||||
|
BeginInvoke(() => _lblTrack.Text = $"Pattern: {_patternName}");
|
||||||
|
|
||||||
|
// Continuously enqueue pattern frames at 100ms per frame
|
||||||
|
int idx = 0;
|
||||||
|
while (!ct.IsCancellationRequested && _isPatternRunning)
|
||||||
|
{
|
||||||
|
var frameA = pattern.ChannelA[idx];
|
||||||
|
var frameB = pattern.ChannelB[idx];
|
||||||
|
_state.EnqueueStream('A', new[] { frameA });
|
||||||
|
_state.EnqueueStream('B', new[] { frameB });
|
||||||
|
idx = (idx + 1) % pattern.ChannelA.Count;
|
||||||
|
|
||||||
|
try { await Task.Delay(100, ct); } catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StopPattern()
|
||||||
|
{
|
||||||
|
_patternCts?.Cancel();
|
||||||
|
_patternCts = null;
|
||||||
|
|
||||||
|
if (_isPatternRunning)
|
||||||
|
{
|
||||||
|
_isPatternRunning = false;
|
||||||
|
if (!IsDisposed)
|
||||||
|
{
|
||||||
|
_lblTrack.Text = "";
|
||||||
|
RefreshButtonStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string ShowInputDialog(string prompt, string title)
|
||||||
|
{
|
||||||
|
using var dlg = new Form
|
||||||
|
{
|
||||||
|
Text = title,
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||||
|
ClientSize = new Size(380, 100),
|
||||||
|
StartPosition = FormStartPosition.CenterParent,
|
||||||
|
MaximizeBox = false,
|
||||||
|
MinimizeBox = false
|
||||||
|
};
|
||||||
|
var lbl = new Label { Text = prompt, Location = new Point(12, 12), Size = new Size(356, 20) };
|
||||||
|
var txt = new TextBox { Location = new Point(12, 36), Size = new Size(356, 22) };
|
||||||
|
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Location = new Point(200, 66), Size = new Size(80, 24) };
|
||||||
|
var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Location = new Point(288, 66), Size = new Size(80, 24) };
|
||||||
|
dlg.Controls.AddRange(new Control[] { lbl, txt, ok, cancel });
|
||||||
|
dlg.AcceptButton = ok;
|
||||||
|
dlg.CancelButton = cancel;
|
||||||
|
return dlg.ShowDialog(this) == DialogResult.OK ? txt.Text.Trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
void UpdateTrayIcon()
|
void UpdateTrayIcon()
|
||||||
{
|
{
|
||||||
var newState = _state.IsSignaling ? "hot"
|
var newState = _state.IsSignaling ? "hot"
|
||||||
|
|||||||
@@ -129,3 +129,28 @@ web game / userscript ──WS──> Substation ──BLE──> Coyote 3.0
|
|||||||
```
|
```
|
||||||
|
|
||||||
Build: `dotnet run -c Release`
|
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`
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Substation;
|
||||||
|
|
||||||
|
public record XToysPattern(string Name, List<WaveFrame> ChannelA, List<WaveFrame> ChannelB);
|
||||||
|
|
||||||
|
public static class XToysPatternImporter
|
||||||
|
{
|
||||||
|
static readonly HttpClient Http = new();
|
||||||
|
|
||||||
|
public static async Task<XToysPattern> ImportAsync(string url)
|
||||||
|
{
|
||||||
|
var patternId = ExtractPatternId(url);
|
||||||
|
if (patternId == null)
|
||||||
|
throw new ArgumentException("Could not extract pattern ID from URL. Expected format: https://xtoys.app/patterns/<ID>");
|
||||||
|
|
||||||
|
var body = JsonSerializer.Serialize(new { data = new { patternID = patternId } });
|
||||||
|
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
|
var response = await Http.PostAsync("https://xtoys.app/api/getPatternv2", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
return Parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int FreqPctToMs(byte freqPct)
|
||||||
|
{
|
||||||
|
// xToys frequency: 0% = deep (1000ms), 100% = buzzy (10ms)
|
||||||
|
// freqPct is the raw 0-100 value from the pattern (already clamped in EvaluatePattern)
|
||||||
|
return (int)Math.Round(1000 - 9.9 * freqPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? ExtractPatternId(string url)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(url, @"/patterns/([A-Za-z0-9_-]+)");
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static XToysPattern Parse(string json)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var pattern = doc.RootElement.GetProperty("result").GetProperty("pattern");
|
||||||
|
var name = pattern.GetProperty("name").GetString() ?? "Unknown";
|
||||||
|
var patternData = pattern.GetProperty("patternData");
|
||||||
|
|
||||||
|
// Resolve slider defaults
|
||||||
|
var sliders = new Dictionary<string, double>();
|
||||||
|
if (patternData.TryGetProperty("ma", out var ma))
|
||||||
|
{
|
||||||
|
foreach (var slider in ma.EnumerateArray())
|
||||||
|
{
|
||||||
|
var key = slider.GetProperty("key").GetString()!;
|
||||||
|
var value = slider.GetProperty("value").GetDouble();
|
||||||
|
sliders[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate patterns to per-tick value arrays
|
||||||
|
var int1 = EvaluatePattern(patternData, "Int1", sliders);
|
||||||
|
var int2 = EvaluatePattern(patternData, "Int2", sliders);
|
||||||
|
var freq1 = EvaluatePattern(patternData, "Freq1", sliders);
|
||||||
|
var freq2 = EvaluatePattern(patternData, "Freq2", sliders);
|
||||||
|
|
||||||
|
// All patterns in a group should have the same total duration.
|
||||||
|
// Use the max length to be safe.
|
||||||
|
int tickCount = Math.Max(int1.Length, Math.Max(int2.Length, Math.Max(freq1.Length, freq2.Length)));
|
||||||
|
|
||||||
|
var channelA = new List<WaveFrame>(tickCount);
|
||||||
|
var channelB = new List<WaveFrame>(tickCount);
|
||||||
|
|
||||||
|
for (int i = 0; i < tickCount; i++)
|
||||||
|
{
|
||||||
|
byte intA = i < int1.Length ? int1[i] : (byte)0;
|
||||||
|
byte intB = i < int2.Length ? int2[i] : (byte)0;
|
||||||
|
// Freq patterns output 0-100 (percentage). Map: 0% = 1000ms deep, 100% = 10ms buzzy.
|
||||||
|
byte freqA = i < freq1.Length ? Freq.Compress(FreqPctToMs(freq1[i])) : Freq.Compress(10);
|
||||||
|
byte freqB = i < freq2.Length ? Freq.Compress(FreqPctToMs(freq2[i])) : Freq.Compress(10);
|
||||||
|
|
||||||
|
channelA.Add(new WaveFrame(
|
||||||
|
new[] { freqA, freqA, freqA, freqA },
|
||||||
|
new[] { intA, intA, intA, intA }));
|
||||||
|
|
||||||
|
channelB.Add(new WaveFrame(
|
||||||
|
new[] { freqB, freqB, freqB, freqB },
|
||||||
|
new[] { intB, intB, intB, intB }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new XToysPattern(name, channelA, channelB);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] EvaluatePattern(JsonElement patternData, string patternName, Dictionary<string, double> sliders)
|
||||||
|
{
|
||||||
|
if (!patternData.TryGetProperty("patterns", out var patterns))
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
if (!patterns.TryGetProperty(patternName, out var patternArr))
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
|
||||||
|
var values = new List<byte>();
|
||||||
|
double lastEnd = 0;
|
||||||
|
|
||||||
|
foreach (var loop in patternArr.EnumerateArray())
|
||||||
|
{
|
||||||
|
var steps = loop.GetProperty("steps");
|
||||||
|
foreach (var step in steps.EnumerateArray())
|
||||||
|
{
|
||||||
|
var type = step.GetProperty("type").GetString() ?? "straight";
|
||||||
|
var time = ResolveExpr(GetStepValue(step, "time"), sliders);
|
||||||
|
var startVal = step.TryGetProperty("start", out var s)
|
||||||
|
? (s.ValueKind == JsonValueKind.Null ? lastEnd : ResolveExpr(GetStepValue(step, "start"), sliders))
|
||||||
|
: lastEnd;
|
||||||
|
var endVal = ResolveExpr(GetStepValue(step, "end"), sliders);
|
||||||
|
|
||||||
|
int ticks = Math.Max(1, (int)Math.Round(time / 0.1));
|
||||||
|
|
||||||
|
for (int t = 0; t < ticks; t++)
|
||||||
|
{
|
||||||
|
double frac = (double)t / ticks;
|
||||||
|
double v;
|
||||||
|
if (type == "sine")
|
||||||
|
v = startVal + (endVal - startVal) * (1 - Math.Cos(frac * Math.PI)) / 2;
|
||||||
|
else
|
||||||
|
v = startVal + (endVal - startVal) * frac;
|
||||||
|
|
||||||
|
values.Add((byte)Math.Clamp((int)Math.Round(v), 0, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEnd = endVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GetStepValue(JsonElement step, string propName)
|
||||||
|
{
|
||||||
|
if (!step.TryGetProperty(propName, out var el))
|
||||||
|
return "0";
|
||||||
|
return el.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => el.GetString() ?? "0",
|
||||||
|
JsonValueKind.Number => el.GetDouble().ToString(),
|
||||||
|
_ => "0"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static double ResolveExpr(string expr, Dictionary<string, double> sliders)
|
||||||
|
{
|
||||||
|
// Replace {var} references, then evaluate simple expressions like "ramp/2"
|
||||||
|
var resolved = Regex.Replace(expr, @"\{(\w+)\}", m =>
|
||||||
|
sliders.TryGetValue(m.Groups[1].Value, out var val) ? val.ToString() : "0");
|
||||||
|
|
||||||
|
// Parse simple arithmetic: number, or number/number
|
||||||
|
var parts = resolved.Split('/');
|
||||||
|
if (parts.Length == 2 && double.TryParse(parts[0], out var a) && double.TryParse(parts[1], out var b))
|
||||||
|
return a / b;
|
||||||
|
return double.TryParse(resolved, out var result) ? result : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user