From bbd9bb6d6b1f344dddd2be566f174d1887aa0259 Mon Sep 17 00:00:00 2001 From: Mute Date: Sat, 8 Aug 2026 19:44:25 +0000 Subject: [PATCH] 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 --- MainForm.cs | 135 ++++++++++++++++++++++++++++++++++++---- NOTES.md | 25 ++++++++ XToysPattern.cs | 162 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 XToysPattern.cs diff --git a/MainForm.cs b/MainForm.cs index 0ec4b4b..b4c2ccf 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -50,11 +50,13 @@ public class MainForm : Form readonly Button _btnMusic; readonly Button _btnLive; + readonly Button _btnPattern; readonly Label _lblTrack; readonly Label _lblAudioDev; readonly ComboBox _cboAudioDev; bool _isMusicRunning; bool _isLiveRunning; + bool _isPatternRunning; WaveOutEvent? _audioOut; Stopwatch? _musicStopwatch; LiveCapture? _liveCapture; @@ -123,31 +125,39 @@ public class MainForm : Form { Text = "Test", Location = new Point(16, 96), - Size = new Size(80, 32) + Size = new Size(68, 32) }; _btnTest.Click += OnTest; _btnMusic = new Button { Text = "Music", - Location = new Point(104, 96), - Size = new Size(80, 32) + Location = new Point(92, 96), + Size = new Size(68, 32) }; _btnMusic.Click += OnMusic; _btnLive = new Button { Text = "Live", - Location = new Point(192, 96), - Size = new Size(80, 32) + Location = new Point(168, 96), + Size = new Size(68, 32) }; _btnLive.Click += OnLive; + _btnPattern = new Button + { + Text = "Pattern", + Location = new Point(244, 96), + Size = new Size(68, 32) + }; + _btnPattern.Click += OnPattern; + _btnStop = new Button { Text = "Stop All", - Location = new Point(280, 96), - Size = new Size(80, 32) + Location = new Point(320, 96), + Size = new Size(76, 32) }; _btnStop.Click += OnStop; @@ -297,7 +307,7 @@ public class MainForm : Form _chkScaleB.CheckedChanged += OnLimitChanged; 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 _iconNeutral = CreateVoltageIcon(Color.Gray); @@ -465,9 +475,11 @@ public class MainForm : Form void RefreshButtonStates() { - _btnTest.Enabled = !_server.HasClient && !IsTestRunning && !_isMusicRunning && !_isLiveRunning; - _btnMusic.Enabled = !_server.HasClient && !_isMusicRunning && !IsTestRunning && !_isLiveRunning; - _btnLive.Enabled = !_server.HasClient && !_isLiveRunning && !IsTestRunning && !_isMusicRunning; + bool busy = IsTestRunning || _isMusicRunning || _isLiveRunning || _isPatternRunning; + _btnTest.Enabled = !_server.HasClient && !busy; + _btnMusic.Enabled = !_server.HasClient && !busy; + _btnLive.Enabled = !_server.HasClient && !busy; + _btnPattern.Enabled = !_server.HasClient && !busy; _btnStop.Enabled = true; } @@ -484,6 +496,10 @@ public class MainForm : Form { _lblTrack.Text = "Live capture"; } + else if (_isPatternRunning) + { + _lblTrack.Text = $"Pattern: {_patternName}"; + } UpdateTrayIcon(); } @@ -550,6 +566,7 @@ public class MainForm : Form { StopMusic(); StopLive(); + StopPattern(); _state.SetStrength('A', 0); _state.SetStrength('B', 0); _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() { var newState = _state.IsSignaling ? "hot" diff --git a/NOTES.md b/NOTES.md index 2081717..c5213f3 100644 --- a/NOTES.md +++ b/NOTES.md @@ -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` diff --git a/XToysPattern.cs b/XToysPattern.cs new file mode 100644 index 0000000..1b6cd04 --- /dev/null +++ b/XToysPattern.cs @@ -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 ChannelA, List ChannelB); + +public static class XToysPatternImporter +{ + static readonly HttpClient Http = new(); + + public static async Task 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/"); + + 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(); + 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(tickCount); + var channelB = new List(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 sliders) + { + if (!patternData.TryGetProperty("patterns", out var patterns)) + return Array.Empty(); + if (!patterns.TryGetProperty(patternName, out var patternArr)) + return Array.Empty(); + + var values = new List(); + 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 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; + } +}