Compare commits

...

3 Commits

Author SHA1 Message Date
mute 1ce20544a4 Add live capture diagnostic logging to live.log
- Logs device name, audio format (bit depth, channels, sample rate)
- Logs dataAvailable callbacks (every 100th): bytes, samples, buffer size
- Logs FFT ticks (every 50th): melody energy, freq, intensity A/B
- Warning if data arrives but buffer doesn't fill
- Logs totals + any exception on stop
- Writes to live.log next to .exe, cleared on each start
2026-08-14 09:54:47 +00:00
mute 496cc5a6b2 Support all 5 xToys pattern types
- script-v3: reworked with initialActions channel mapping, freq detection
  by name, single-channel duplication, full expression evaluator (pow, arithmetic)
- basic-v2: handle frequencyControl:true, 3-channel (ch3 ignored)
- funscript/draw: resample {at,pos} timeline to 100ms ticks via linear interpolation
- audio: resample flat float array to 100ms ticks using length in seconds
- EvaluateExpression replaces ResolveExpr: DataTable.Compute + pow() pre-processing
- All types: single-channel patterns duplicate to both A/B
- All types: missing frequency data uses fixed 150ms default
2026-08-14 09:49:58 +00:00
mute 1ab397767a Cleanup pass: remove dead code, fix bugs, simplify
Dead code removed:
- MusicAnalyzer.BuildWaveFrame (replaced by DrumDetector + BuildMelodyFrame)
- MusicAnalyzer.ExtractFeatures simplified to melody-only (removed rhythm band)
- MusicAnalyzer.RhythmLow/RhythmHigh constants, TickFeature.RhythmFlux
- LiveCapture._liveMaxFlux + _prevRhythmMag (computed, never read)
- State.ActualA/ActualB, State.LastSentA/LastSentB (set, never read)
- Program.cs StrengthChanged handler (only wrote to dead fields)
- Command.Mode property (never referenced)
- DrumDetector.FreqKick/Snare/Brass/Silent static arrays (unused)

Bugs fixed:
- DrumDetector.BuildFrame: freqBytes was byte[16], now byte[4]
- CoyoteDevice fallback: use nameFilter param instead of hardcoded string

Refactoring:
- Server.DoStatus calls BuildStatusPush(null) instead of duplicating
- HeatMap: removed redundant colW assignment
- Removed unused System.Numerics imports from LiveCapture, MusicAnalyzer
2026-08-09 14:01:19 +00:00
10 changed files with 417 additions and 157 deletions
+1 -1
View File
@@ -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)
+1 -6
View File
@@ -17,11 +17,6 @@ public class DrumDetector
readonly bool[] _subSnare = new bool[4]; readonly bool[] _subSnare = new bool[4];
readonly bool[] _subBrass = new bool[4]; readonly bool[] _subBrass = new bool[4];
static readonly byte[] FreqKick = Freq.Compress4(new[] { 150, 150, 150, 150 });
static readonly byte[] FreqSnare = Freq.Compress4(new[] { 50, 50, 50, 50 });
static readonly byte[] FreqBrass = Freq.Compress4(new[] { 10, 10, 10, 10 });
static readonly byte[] FreqSilent = Freq.Compress4(new[] { 10, 10, 10, 10 });
public void ProcessWindow(double[] magnitude, int sampleRate, int windowSize, int subTickIndex) public void ProcessWindow(double[] magnitude, int sampleRate, int windowSize, int subTickIndex)
{ {
if (subTickIndex < 0 || subTickIndex > 3) return; if (subTickIndex < 0 || subTickIndex > 3) return;
@@ -66,7 +61,7 @@ public class DrumDetector
public WaveFrame BuildFrame() public WaveFrame BuildFrame()
{ {
var intensity = new byte[4]; var intensity = new byte[4];
var freqBytes = new byte[16]; // 4 sub-ticks × 4 bytes (but we use per-sub-tick freq) var freqBytes = new byte[4];
for (int i = 0; i < 4; i++) for (int i = 0; i < 4; i++)
{ {
+1 -2
View File
@@ -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++)
{ {
+49 -9
View File
@@ -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,14 +16,29 @@ 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(); 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;
public LiveCapture(State state, MMDevice device) public LiveCapture(State state, MMDevice device)
@@ -39,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;
@@ -46,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()
@@ -62,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++)
@@ -84,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()
@@ -94,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;
@@ -123,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;
} }
@@ -131,13 +165,11 @@ 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);
// Drum detection: map this FFT window to a sub-tick (0-3) // Drum detection: map this FFT window to a sub-tick (0-3)
@@ -158,6 +190,14 @@ public class LiveCapture : IDisposable
_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;
} }
+3 -65
View File
@@ -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,7 +52,6 @@ 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();
@@ -105,44 +83,4 @@ public static class MusicAnalyzer
return new WaveFrame(freqB, intB); return new WaveFrame(freqB, intB);
} }
public static (WaveFrame chA, WaveFrame chB) BuildWaveFrame(TickFeature tf, double maxFlux, 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 normalizedEnergy = maxMelodyEnergy > 0 ? avgEnergy / maxMelodyEnergy : 0;
int melodyIntensity = (int)Math.Round(normalizedEnergy * 80);
melodyIntensity = Math.Clamp(melodyIntensity, 0, 100);
double weightedFreq = 0;
double totalWeight = 0;
foreach (var f in tf.MelodyFreqSamples)
{
weightedFreq += f * f;
totalWeight += f;
}
double avgMelodyHz = totalWeight > 0 ? weightedFreq / totalWeight : 500;
int estimsMs = MapPitchToPeriod(avgMelodyHz);
var intB = new[]
{
(byte)melodyIntensity, (byte)melodyIntensity,
(byte)melodyIntensity, (byte)melodyIntensity
};
var freqB = Freq.Compress4(new[] { estimsMs, estimsMs, estimsMs, estimsMs });
return (new WaveFrame(freqA, intA), new WaveFrame(freqB, intB));
}
} }
-6
View File
@@ -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));
} }
} }
-4
View File
@@ -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; }
+1 -6
View File
@@ -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)
-5
View File
@@ -15,8 +15,6 @@ 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 int LastSentA, LastSentB;
public double LimitScaleA, LimitScaleB; public double LimitScaleA, LimitScaleB;
public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB; public byte[]? LastFreqA, LastIntA, LastFreqB, LastIntB;
public bool LastActiveA, LastActiveB; public bool LastActiveA, LastActiveB;
@@ -133,8 +131,6 @@ 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;
LastSentB = valB;
LimitScaleA = DesiredA > 0 ? (double)valA / DesiredA : 0; LimitScaleA = DesiredA > 0 ? (double)valA / DesiredA : 0;
LimitScaleB = DesiredB > 0 ? (double)valB / DesiredB : 0; LimitScaleB = DesiredB > 0 ? (double)valB / DesiredB : 0;
DirtyA = false; DirtyA = false;
@@ -167,7 +163,6 @@ 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);
+361 -53
View File
@@ -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;
}
} }
} }