Files
Substation/XToysPattern.cs
T

471 lines
18 KiB
C#
Raw Normal View History

2026-08-14 09:49:58 +00:00
using System.Data;
using System.Globalization;
2026-08-08 19:44:25 +00:00
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Substation;
public record XToysPattern(string Name, List<WaveFrame> ChannelA, List<WaveFrame> ChannelB);
public static class XToysPatternImporter
{
static readonly HttpClient Http = new();
public static async Task<XToysPattern> ImportAsync(string url)
{
var patternId = ExtractPatternId(url);
if (patternId == null)
throw new ArgumentException("Could not extract pattern ID from URL. Expected format: https://xtoys.app/patterns/<ID>");
var body = JsonSerializer.Serialize(new { data = new { patternID = patternId } });
var content = new StringContent(body, Encoding.UTF8, "application/json");
var response = await Http.PostAsync("https://xtoys.app/api/getPatternv2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return Parse(json);
}
static int FreqPctToMs(byte freqPct)
{
// xToys frequency: 0% = deep (1000ms), 100% = buzzy (10ms)
return (int)Math.Round(1000 - 9.9 * freqPct);
}
static string? ExtractPatternId(string url)
{
var match = Regex.Match(url, @"/patterns/([A-Za-z0-9_-]+)");
return match.Success ? match.Groups[1].Value : null;
}
static XToysPattern Parse(string json)
{
using var doc = JsonDocument.Parse(json);
var pattern = doc.RootElement.GetProperty("result").GetProperty("pattern");
var name = pattern.GetProperty("name").GetString() ?? "Unknown";
2026-08-14 09:49:58 +00:00
var patternType = pattern.GetProperty("type").GetString() ?? "script-v3";
2026-08-08 19:44:25 +00:00
var patternData = pattern.GetProperty("patternData");
2026-08-14 09:49:58 +00:00
return patternType switch
2026-08-08 19:44:25 +00:00
{
2026-08-14 09:49:58 +00:00
"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())
2026-08-08 19:44:25 +00:00
{
2026-08-14 09:49:58 +00:00
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
2026-08-08 19:44:25 +00:00
}
2026-08-14 09:49:58 +00:00
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);
2026-08-08 19:44:25 +00:00
}
2026-08-14 09:49:58 +00:00
// Single channel → duplicate
if (intB.Length == 0 && intA.Length > 0) intB = intA;
if (intA.Length == 0 && intB.Length > 0) intA = intB;
2026-08-08 19:44:25 +00:00
2026-08-14 09:49:58 +00:00
int tickCount = Math.Max(intA.Length, intB.Length);
if (tickCount == 0) tickCount = 1;
2026-08-08 19:44:25 +00:00
var channelA = new List<WaveFrame>(tickCount);
var channelB = new List<WaveFrame>(tickCount);
for (int i = 0; i < tickCount; i++)
{
2026-08-14 09:49:58 +00:00
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;
2026-08-08 19:44:25 +00:00
2026-08-14 09:49:58 +00:00
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;
2026-08-08 19:44:25 +00:00
2026-08-14 09:49:58 +00:00
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 }));
2026-08-08 19:44:25 +00:00
}
return new XToysPattern(name, channelA, channelB);
}
2026-08-14 09:49:58 +00:00
static byte[] ResampleAudio(JsonElement channelData, double scale, double lengthSec)
2026-08-08 19:44:25 +00:00
{
2026-08-14 09:49:58 +00:00
// 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 ──
2026-08-08 19:44:25 +00:00
2026-08-14 09:49:58 +00:00
static Dictionary<string, double> ResolveSliders(JsonElement patternData)
{
var sliders = new Dictionary<string, double>();
if (patternData.TryGetProperty("ma", out var ma))
{
foreach (var slider in ma.EnumerateArray())
{
var key = slider.GetProperty("key").GetString()!;
var value = slider.GetProperty("value").GetDouble();
sliders[key] = value;
}
}
return sliders;
}
static byte[] EvaluateLoops(JsonElement loopsElement, Dictionary<string, double> sliders)
{
2026-08-08 19:44:25 +00:00
var values = new List<byte>();
double lastEnd = 0;
2026-08-14 09:49:58 +00:00
foreach (var loop in loopsElement.EnumerateArray())
2026-08-08 19:44:25 +00:00
{
var steps = loop.GetProperty("steps");
foreach (var step in steps.EnumerateArray())
{
var type = step.GetProperty("type").GetString() ?? "straight";
2026-08-14 09:49:58 +00:00
var time = EvaluateExpression(GetStepValue(step, "time"), sliders);
2026-08-08 19:44:25 +00:00
var startVal = step.TryGetProperty("start", out var s)
2026-08-14 09:49:58 +00:00
? (s.ValueKind == JsonValueKind.Null ? lastEnd : EvaluateExpression(GetStepValue(step, "start"), sliders))
2026-08-08 19:44:25 +00:00
: lastEnd;
2026-08-14 09:49:58 +00:00
var endVal = EvaluateExpression(GetStepValue(step, "end"), sliders);
2026-08-08 19:44:25 +00:00
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",
2026-08-14 09:49:58 +00:00
JsonValueKind.Number => el.GetDouble().ToString(CultureInfo.InvariantCulture),
2026-08-08 19:44:25 +00:00
_ => "0"
};
}
2026-08-14 09:49:58 +00:00
static double EvaluateExpression(string expr, Dictionary<string, double> sliders)
2026-08-08 19:44:25 +00:00
{
2026-08-14 09:49:58 +00:00
// Replace {var} references with slider values
2026-08-08 19:44:25 +00:00
var resolved = Regex.Replace(expr, @"\{(\w+)\}", m =>
2026-08-14 09:49:58 +00:00
sliders.TryGetValue(m.Groups[1].Value, out var val)
? val.ToString(CultureInfo.InvariantCulture)
: "0");
// Try plain number first
if (double.TryParse(resolved, CultureInfo.InvariantCulture, out var simple))
return simple;
// Pre-process pow(a, b) calls — DataTable.Compute doesn't support pow
resolved = Regex.Replace(resolved, @"pow\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)", m =>
{
double a = double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture);
double b = double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture);
return Math.Pow(a, b).ToString("G15", CultureInfo.InvariantCulture);
});
2026-08-08 19:44:25 +00:00
2026-08-14 09:49:58 +00:00
// 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;
}
2026-08-08 19:44:25 +00:00
}
}