From 4a5686ae8b503775f62dad08d187704f3e54ff39 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Fri, 12 Mar 2021 12:59:38 +0100 Subject: [PATCH 01/42] create linux-compatible simulate.cs --- Circuit/Circuit.csproj | 11 +- Circuit/Circuit.sln | 17 + Circuit/Simulate.cs | 1093 +++++++++++++++++++++++++++ Circuit/packages-microsoft-prod.deb | Bin 0 -> 3124 bytes 4 files changed, 1119 insertions(+), 2 deletions(-) create mode 100644 Circuit/Circuit.sln create mode 100644 Circuit/Simulate.cs create mode 100644 Circuit/packages-microsoft-prod.deb diff --git a/Circuit/Circuit.csproj b/Circuit/Circuit.csproj index fae5e2a7..e617a4a7 100644 --- a/Circuit/Circuit.csproj +++ b/Circuit/Circuit.csproj @@ -1,12 +1,17 @@  - netstandard2.0 - Library + net5.0 + Exe Circuit Circuit Copyright © 2020 1.0.0.0 1.0.0.0 + Circuit.Simulate + true + + + ;DEBUG;$([MSBUILD]::GETTARGETFRAMEWORKIDENTIFIER('$(TARGETFRAMEWORK)'));$([MSBUILD]::GETTARGETFRAMEWORKIDENTIFIER('$(TARGETFRAMEWORK)'))$([MSBuild]::GetTargetFrameworkVersion('$(TargetFramework)', 2)); @@ -22,5 +27,7 @@ + + \ No newline at end of file diff --git a/Circuit/Circuit.sln b/Circuit/Circuit.sln new file mode 100644 index 00000000..12c7114d --- /dev/null +++ b/Circuit/Circuit.sln @@ -0,0 +1,17 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Circuit", "Circuit.csproj", "{04D7B263-7882-4A12-9E74-07B39A89E1A7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Circuit/Simulate.cs b/Circuit/Simulate.cs new file mode 100644 index 00000000..89e0b07e --- /dev/null +++ b/Circuit/Simulate.cs @@ -0,0 +1,1093 @@ +using ComputerAlgebra.LinqCompiler; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Collections.ObjectModel; +using System.ComponentModel; +using MathNet.Numerics.IntegralTransforms; +using System.Numerics; +using System.Text; +using System.Collections; + +namespace Circuit +{ + + class NullDevice : Device + { + private Guid classid; + + public override Stream Open(Stream.SampleHandler Callback, Channel[] Input, Channel[] Output) + { + return new NullStream(ProcessSamples); + } + + private void ProcessSamples(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) + { + // The time covered by these samples. + double timespan = Count / Rate; + + // Apply input gain. + for (int i = 0; i < In.Length; ++i) + { + Channel ch = InputChannels[i]; + ; + System.Console.WriteLine(In[i].Amplify(1)); + } + + + // Apply output gain. + for (int i = 0; i < Out.Length; ++i) + { + Channel ch = OutputChannels[i]; + System.Console.WriteLine(Out[i].Amplify(1)); + } + } + } + public class Simulate + { + public static void Main(string[] args) { + Schematic schema = Schematic.Load(args[0]); + Device device = new NullDevice(); + InputChannel input = new InputChannel(1); + Channel[] Inputs = {}; + Channel[] Outputs = {new OutputChannel(1)}; + LiveSimulation simulation = new LiveSimulation(schema, device, Inputs, Outputs); + } + } + + + public class LiveSimulation + { + private object sync = new object(); + protected Circuit circuit = null; + protected Simulation simulation = null; + + protected ObservableCollection _inputChannels = new ObservableCollection(); + protected ObservableCollection _outputChannels = new ObservableCollection(); + public ObservableCollection InputChannels { get { return _inputChannels; } } + public ObservableCollection OutputChannels { get { return _outputChannels; } } + private double inputGain = 1.0; + private double outputGain = 1.0; + private Dictionary inputs = new Dictionary(); + private List probes = new List(); + private int clock = -1; + protected int oversample = 8; + protected Stream stream = null; + protected System.Timers.Timer timer; + public LiveSimulation(Schematic Simulate, Device Device, Channel[] Inputs, Channel[] Outputs) + { + System.Console.WriteLine("Creating simulation"); + try + { + // Make a clone of the schematic so we can mess with it. + Schematic clone = Schematic.Deserialize(Simulate.Serialize()); + clone.Elements.ItemAdded += OnElementAdded; + clone.Elements.ItemRemoved += OnElementRemoved; + + // Build the circuit from the schematic. + circuit = clone.Build(); + System.Console.WriteLine("Cloned circuit"); + + // Create the input and output controls. + IEnumerable components = circuit.Components; + + // Create audio input channels. + for (int i = 0; i < Inputs.Length; ++i) + InputChannels.Add(new InputChannel(i) { Name = Inputs[i].Name }); + + System.Console.WriteLine("Added inputs"); + + ComputerAlgebra.Expression speakers = 0; + + foreach (Component i in components) + { + Symbol S = i.Tag as Symbol; + if (S == null) + continue; + + SymbolControl tag = (SymbolControl)S.Tag; + if (tag == null) + continue; + if (i is Speaker output) + speakers += output.Out; + + // Create input controls. + if (i is Input input) + { + + ComputerAlgebra.Expression In = input.In; + inputs[In] = new SignalChannel(0); + + } + } + + System.Console.WriteLine("Added components"); + + // Create audio output channels. + for (int i = 0; i < Outputs.Length; ++i) + { + OutputChannel c = new OutputChannel(i) { Name = Outputs[i].Name, Signal = speakers }; + c.PropertyChanged += (o, e) => { if (e.PropertyName == "Signal") RebuildSolution(); }; + OutputChannels.Add(c); + } + + System.Console.WriteLine("Beginning audio processing"); + + // Begin audio processing. + if (Inputs.Any() || Outputs.Any()) + stream = Device.Open(ProcessSamples, Inputs, Outputs); + else + stream = new NullStream(ProcessSamples); + + System.Console.WriteLine("Rebuilding solution"); + + }catch (Exception Ex){ + System.Console.WriteLine(Ex); + } + } + + + private void OnElementAdded(object sender, ElementEventArgs e) + { + if (e.Element is Symbol && ((Symbol)e.Element).Component is Probe) + { + Probe probe = (Probe)((Symbol)e.Element).Component; + probe.Signal = new Signal() + { + Name = probe.V.ToString(), + }; + lock (sync) + { + probes.Add(probe); + if (simulation != null) + simulation.Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(); + } + } + } + + private void OnElementRemoved(object sender, ElementEventArgs e) + { + if (e.Element is Symbol && ((Symbol)e.Element).Component is Probe) + { + Probe probe = (Probe)((Symbol)e.Element).Component; + lock (sync) + { + probes.Remove(probe); + if (simulation != null) + simulation.Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(); + } + } + } + + + protected int iterations = 8; + /// + /// Max iterations for numerical algorithms. + /// + public int Iterations + { + get { return iterations; } + set { iterations = value; RebuildSolution(); NotifyChanged(nameof(Iterations)); } + } + + private void NotifyChanged(string p) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p)); + } + public event PropertyChangedEventHandler PropertyChanged; + + public int Oversample + { + get { return oversample; } + set { oversample = value; RebuildSolution(); NotifyChanged(nameof(Oversample)); } + } + + private void ProcessSamples(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) + { + // The time covered by these samples. + double timespan = Count / Rate; + + // Apply input gain. + for (int i = 0; i < In.Length; ++i) + { + Channel ch = InputChannels[i]; + double peak = In[i].Amplify(inputGain); + ch.SampleSignalLevel(peak, timespan); + } + + // Run the simulation. + lock (sync) + { + if (simulation != null) + RunSimulation(Count, In, Out, Rate); + else + foreach (SampleBuffer i in Out) + i.Clear(); + } + + // Apply output gain. + for (int i = 0; i < Out.Length; ++i) + { + Channel ch = OutputChannels[i]; + double peak = Out[i].Amplify(outputGain); + ch.SampleSignalLevel(peak, timespan); + } + } + + // These lists only ever grow, but they should never contain more than 10s of items. + readonly List inputBuffers = new List(); + readonly List outputBuffers = new List(); + private void RunSimulation(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) + { + try + { + // If the sample rate changed, we need to kill the simulation and let the foreground rebuild it. + if (Rate != (double)simulation.SampleRate) + { + simulation = null; + Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); + RebuildSolutionThread.Start(); + return; + } + + inputBuffers.Clear(); + foreach (Channel i in inputs.Values) + { + if (i is InputChannel input) + inputBuffers.Add(In[input.Index].Samples); + else if (i is SignalChannel channel) + inputBuffers.Add(channel.Buffer(Count, simulation.Time, simulation.TimeStep)); + } + + outputBuffers.Clear(); + foreach (Probe i in probes) + outputBuffers.Add(i.AllocBuffer(Count)); + for (int i = 0; i < Out.Length; ++i) + outputBuffers.Add(Out[i].Samples); + + // Process the samples! + simulation.Run(Count, inputBuffers, outputBuffers); + + foreach (Probe i in probes) + i.Signal.AddSamples(clock, i.Buffer); + } + catch (SimulationDiverged Ex) + { + Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); + // If the simulation diverged more than one second ago, reset it and hope it doesn't happen again. + simulation = null; + if ((double)Ex.At > Rate) + RebuildSolutionThread.Start(); + foreach (SampleBuffer i in Out) + i.Clear(); + } + catch (Exception Ex) + { + // If there was a more serious error, kill the simulation so the user can fix it. + simulation = null; + foreach (SampleBuffer i in Out) + i.Clear(); + } + } + + private void RebuildSolution() + { + Stream stream = new NullStream(ProcessSamples); + + lock (sync) + { + simulation = null; + + try + { + ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (stream.SampleRate * Oversample); + TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); + + simulation = new Simulation(solution) + { + Input = inputs.Keys.ToArray(), + Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(), + Oversample = Oversample, + Iterations = Iterations, + }; + System.Console.WriteLine("Solution was rebuilt"); + + } + catch (Exception Ex) + { + System.Console.WriteLine(Ex); + } + + } + } + } + + /// + /// Base class for a running audio stream. + /// + public abstract class Stream + { + private Channel[] inputs, outputs; + + protected Stream(Channel[] Inputs, Channel[] Outputs) { inputs = Inputs; outputs = Outputs; } + + /// + /// Handler for accepting new samples in and writing output samples out. + /// + /// + public delegate void SampleHandler(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate); + + public Channel[] InputChannels { get { return inputs; } } + public Channel[] OutputChannels { get { return outputs; } } + + public abstract double SampleRate { get; } + + public abstract void Stop(); + } + + /// + /// Devices describe the supported audio stream properties. + /// + public abstract class Device + { + protected string name; + public string Name { get { return name; } } + + protected Channel[] inputs; + public Channel[] InputChannels { get { return inputs; } } + protected Channel[] outputs; + public Channel[] OutputChannels { get { return outputs; } } + + protected Device() { } + protected Device(string Name) { name = Name; } + + public abstract Stream Open(Stream.SampleHandler Callback, Channel[] Input, Channel[] Output); + } + + /// + /// This object defines a pinned sample array, suitable for sharing + /// with native code. + /// + public class SampleBuffer : IDisposable + { + /// + /// Number of samples contained in this buffer. + /// + public uint Count => (uint)Samples.Length; + + /// + /// Samples in this buffer. + /// + public double[] Samples { get; private set; } + private GCHandle pin; + + /// + /// Access samples of this buffer. + /// + /// + /// + public double this[int i] { get { return Samples[i]; } set { Samples[i] = value; } } + + /// + /// Pointer to raw samples in this buffer. + /// + public IntPtr Raw { get { return pin.AddrOfPinnedObject(); } } + + private object tag = null; + /// + /// User defined tag object. + /// + public object Tag { get { return tag; } set { tag = value; } } + + public SampleBuffer(int Count) + { + Samples = new double[Count]; + pin = GCHandle.Alloc(Samples, GCHandleType.Pinned); + } + + ~SampleBuffer() { Dispose(false); } + public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + private void Dispose(bool Disposing) + { + if (pin.IsAllocated) + pin.Free(); + + Samples = null; + } + + /// + /// Set this buffer to have the zero signal. + /// + public void Clear() + { + Util.ZeroMemory(Raw, Count * sizeof(double)); + } + + /// + /// Amplify the samples in this buffer. + /// + /// + public double Amplify(double Gain) + { + return Util.Amplify(Raw, Count, Gain); + } + } + + + public static class Util + { + // C# is great, but man generics suck compared to templates. + // TODO: simd? + public static unsafe void LEi16ToLEf64(IntPtr source, IntPtr destination, uint count) + { + short* from = (short*)source.ToPointer(); + double* to = (double*)destination.ToPointer(); + double scale = 1.0 / ((1 << 15) - 1); + for (int i = 0; i < count; i++) + to[i] = from[i] * scale; + } + public static unsafe void LEi32ToLEf64(IntPtr source, IntPtr destination, uint count) + { + int* from = (int*)source.ToPointer(); + double* to = (double*)destination.ToPointer(); + double scale = 1.0 / ((1L << 31) - 1); + for (int i = 0; i < count; i++) + to[i] = from[i] * scale; + } + public static unsafe void LEf32ToLEf64(IntPtr source, IntPtr destination, uint count) + { + float* from = (float*)source.ToPointer(); + double* to = (double*)destination.ToPointer(); + for (int i = 0; i < count; i++) + to[i] = from[i]; + } + + public static unsafe void LEf64ToLEi16(IntPtr source, IntPtr destination, uint count) + { + double* from = (double*)source.ToPointer(); + short* to = (short*)destination.ToPointer(); + double max = (1 << 15) - 1; + for (int i = 0; i < count; i++) + to[i] = (short)Math.Max(Math.Min(from[i] * max, max), -max); + } + public static unsafe void LEf64ToLEi32(IntPtr source, IntPtr destination, uint count) + { + double* from = (double*)source.ToPointer(); + int* to = (int*)destination.ToPointer(); + double max = (1L << 31) - 1; + for (int i = 0; i < count; i++) + to[i] = (int)Math.Max(Math.Min(from[i] * max, max), -max); + } + public static unsafe void LEf64ToLEf32(IntPtr source, IntPtr destination, uint count) + { + double* from = (double*)source.ToPointer(); + float* to = (float*)destination.ToPointer(); + for (int i = 0; i < count; i++) + to[i] = (float)from[i]; + } + + public static unsafe double Amplify(IntPtr Samples, uint count, double Gain) + { + double* s = (double*)Samples.ToPointer(); + double peak = 0.0; + for (int i = 0; i < count; i++) + { + s[i] = s[i] * Gain; + // TODO: Absolute value of s[i]? + peak = Math.Max(peak, s[i]); + } + return peak; + } + + public static unsafe void CopyMemory(IntPtr destination, IntPtr source, uint count) => Unsafe.CopyBlock(destination.ToPointer(), source.ToPointer(), count); + + public static unsafe void ZeroMemory(IntPtr startAddress, uint count) => Unsafe.InitBlockUnaligned(startAddress.ToPointer(), 0, count); + } + + public class NullStream : Stream + { + public override double SampleRate { get { return 48000; } } + + private readonly SampleHandler callback; + + private bool run = true; + private Thread thread; + + public NullStream(SampleHandler Callback) : base(new Channel[] { }, new Channel[] { }) + { + callback = Callback; + thread = new Thread(Proc); + thread.Start(); + } + + private void Proc() + { + SampleBuffer[] input = new SampleBuffer[] { }; + SampleBuffer[] output = new SampleBuffer[] { }; + + long samples = 0; + DateTime start = DateTime.Now; + while (run) + { + // Run at ~50 callbacks/second. This doesn't need to be super precise. In + // practice, Thread.Sleep is going to be +/- 10s of ms, but we'll still deliver + // the right number of samples on average. + Thread.Sleep(20); + double elapsed = (DateTime.Now - start).TotalSeconds; + int needed_samples = (int)(Math.Round(elapsed * SampleRate) - samples); + callback(needed_samples, input, output, SampleRate); + samples += needed_samples; + } + } + + public override void Stop() + { + run = false; + thread.Join(); + thread = null; + } + } + + public abstract class Channel : INotifyPropertyChanged + { + private string name = ""; + public string Name { get { return name; } set { name = value; NotifyChanged(nameof(Name)); } } + + + private double signalLevel = 0; + public double SignalLevel { get { return signalLevel; } } + /// + /// Update the signal level of this channel. + /// + /// + /// + public void SampleSignalLevel(double level, double time) + { + double a = Frequency.DecayRate(time, 0.25); + signalLevel = Math.Max(level, level * a + signalLevel * (1 - a)); + } + + public void ResetSignalLevel() { signalLevel = 0; } + + // INotifyPropertyChanged. + protected void NotifyChanged(string p) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p)); + } + public event PropertyChangedEventHandler PropertyChanged; + } + + /// + /// Channel of audio data. + /// + public class InputChannel : Channel + { + private int index = 0; + public int Index { get { return index; } } + + public InputChannel(int Index) { index = Index; } + } + + /// + /// Channel generated by a signal expression. + /// + public class SignalChannel : Channel + { + private Func signal; + + private double[] buffer = null; + public double[] Buffer(int Count, double t, double dt) + { + if (buffer == null || buffer.Length != Count) + buffer = new double[Count]; + for (int i = 0; i < Count; ++i, t += dt) + buffer[i] = signal(t); + return buffer; + } + + public SignalChannel(ComputerAlgebra.Expression Signal) + { + signal = Signal.Compile>(Component.t); + } + } + + /// + /// Output audio channel. + /// + public class OutputChannel : Channel + { + private int index = 0; + public int Index { get { return index; } } + + private ComputerAlgebra.Expression signal = 0; + public ComputerAlgebra.Expression Signal { get { return signal; } set { signal = value; NotifyChanged(nameof(Signal)); } } + + public OutputChannel(int Index) { index = Index; } + } + + public class Frequency + { + private static string[] Notes = { "C", "C\u266f", "D", "D\u266f", "E", "F", "F\u266f", "G", "G\u266f", "A", "A\u266f", "B" }; + public static string ToNote(double f, double A4) + { + // Halfsteps above C0 + double halfsteps = (Math.Log(f / A4, 2.0) + 5.0) * 12.0 - 3.0; + if (halfsteps < 0 || double.IsNaN(halfsteps) || double.IsInfinity(halfsteps)) + return ""; + + int note = (int)Math.Round(halfsteps) % 12; + int octave = (int)Math.Round(halfsteps) / 12; + int cents = (int)Math.Round((halfsteps - Math.Round(halfsteps)) * 100); + + StringBuilder sb = new StringBuilder(Notes[note]); + sb.Append(IntToSubscript(octave)); + sb.Append(' '); + if (cents >= 0) + sb.Append('+'); + sb.Append(cents); + sb.Append('\u00A2'); + return sb.ToString(); + } + + private static string IntToSubscript(int x) + { + string chars = x.ToString(); + + StringBuilder ret = new StringBuilder(); + foreach (char i in chars) + { + if (i == '-') + ret.Append((char)0x208B); + else + ret.Append((char)(0x2080 + i - '0')); + } + return ret.ToString(); + } + + public static double Estimate(double[] Samples, int Decimate, out double Phase) + { + Complex[] data = DecimateSignal(Samples, Decimate); + int N = data.Length; + Fourier.Forward(data); + // Zero the DC bin. + data[0] = 0.0; + + double f = 0.0; + double max = 0.0; + Phase = 0.0; + + // Find largest frequency in FFT. + for (int i = 1; i < N / 2 - 1; ++i) + { + double x; + Complex m = LogParabolaMax(data[i - 1], data[i], data[i + 1], out x); + + if (m.Magnitude > max) + { + max = m.Magnitude; + f = i + x; + Phase = m.Phase; + } + } + + // Check if this is a harmonic of another frequency (the fundamental frequency). + double f0 = f; + for (int h = 2; h < 5; ++h) + { + int i = (int)Math.Round(f / h); + if (i >= 1) + { + double x; + Complex m = LogParabolaMax(data[i - 1], data[i], data[i + 1], out x); + + if (m.Magnitude * 5.0 > max) + { + f0 = f / h; + Phase = m.Phase; + } + } + } + + return f0; + } + + private static double Hann(int i, int N) { return 0.5 * (1.0 - Math.Cos((2.0 * Math.PI * i) / (N - 1))); } + + // Fit parabola to 3 bins and find the maximum. + private static Complex LogParabolaMax(Complex A, Complex B, Complex C, out double x) + { + double a = A.Magnitude; + double b = B.Magnitude; + double c = C.Magnitude; + + if (b > a && b > c) + { + // Parabola fitting is more accurate in log magnitude. + a = Math.Log(a); + b = Math.Log(b); + c = Math.Log(c); + + // Maximum location. + x = (a - c) / (2.0 * (a - 2.0 * b + c)); + + // Maximum value. + return Complex.FromPolarCoordinates( + Math.Exp(b - x * (a - c) / 4.0), + (B - x * (A - C) / 4.0).Phase); + } + else + { + x = 0.0; + return B; + } + } + + private static Complex[] DecimateSignal(double[] Block, int Decimate) + { + int N = Block.Length / Decimate; + Complex[] data = new Complex[N]; + + // Decimate input audio with low pass filter. + for (int i = 0; i < N; ++i) + { + double v = 0.0; + for (int j = 0; j < Decimate; ++j) + v += Block[i * Decimate + j]; + data[i] = new Complex(v * Hann(i, N), 0.0); + } + return data; + } + + /// + /// Get the parameter for a first-order IIR filter. + /// + /// The time between steps. + /// The time to decay by half. + public static double DecayRate(double timestep, double halflife) + { + return Math.Exp(timestep / halflife * Math.Log(0.5)); + } + } + + public enum ScopeMode + { + Oscilloscope, + Spectrogram, + } + + public class SignalEventArgs : EventArgs + { + private Signal e; + public Signal Signal { get { return e; } } + + public SignalEventArgs(Signal E) { e = E; } + } + + public class TickEventArgs : EventArgs + { + private long clock; + public long Clock { get { return Clock; } } + + public TickEventArgs(long Clock) { clock = Clock; } + } + + /// + /// Collection of Signals. + /// + public class SignalCollection : IEnumerable, IEnumerable + { + protected List x = new List(); + + public Signal this[int index] { get { lock (x) return x[index]; } } + + public delegate void SignalEventHandler(object sender, SignalEventArgs e); + + private List itemAdded = new List(); + protected void OnItemAdded(SignalEventArgs e) { foreach (SignalEventHandler i in itemAdded) i(this, e); } + public event SignalEventHandler ItemAdded + { + add { itemAdded.Add(value); } + remove { itemAdded.Remove(value); } + } + + private List itemRemoved = new List(); + protected void OnItemRemoved(SignalEventArgs e) { foreach (SignalEventHandler i in itemRemoved) i(this, e); } + public event SignalEventHandler ItemRemoved + { + add { itemRemoved.Add(value); } + remove { itemRemoved.Remove(value); } + } + + private long clock = 0; + public long Clock { get { return clock; } } + + private double sampleRate = 1; + public double SampleRate { get { return sampleRate; } } + + public void TickClock(int SampleCount, double SampleRate) + { + sampleRate = SampleRate; + + int truncate = (int)sampleRate / 4; + + // Remove the signals that we didn't get data for. + ForEach(i => + { + if (i.Clock < clock) + i.Clear(); + else + i.Truncate(truncate); + }); + + clock += SampleCount; + } + + // ICollection + public int Count { get { lock (x) return x.Count; } } + public void Add(Signal item) + { + lock (x) x.Add(item); + OnItemAdded(new SignalEventArgs(item)); + } + public void AddRange(IEnumerable items) + { + foreach (Signal i in items) + Add(i); + } + public void Clear() + { + Signal[] removed = x.ToArray(); + lock (x) x.Clear(); + + foreach (Signal i in removed) + OnItemRemoved(new SignalEventArgs(i)); + } + public bool Contains(Signal item) { lock (x) return x.Contains(item); } + public void CopyTo(Signal[] array, int arrayIndex) { lock (x) x.CopyTo(array, arrayIndex); } + public bool Remove(Signal item) + { + bool ret; + lock (x) ret = x.Remove(item); + if (ret) + OnItemRemoved(new SignalEventArgs(item)); + return ret; + } + public void RemoveRange(IEnumerable items) + { + foreach (Signal i in items) + Remove(i); + } + + /// + /// This is thread safe. + /// + /// + public void ForEach(Action f) + { + lock (x) foreach (Signal i in x) + f(i); + } + + // IEnumerable + public IEnumerator GetEnumerator() { return x.GetEnumerator(); } + + IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } + } + + public class Signal : IEnumerable + { + private List samples = new List(); + + private string name; + /// + /// Name of this signal. + /// + public string Name { get { return name; } set { name = value; } } + public override string ToString() { return name; } + + /// + /// Pen to draw this signal with. + /// + + private object tag; + public object Tag { get { return tag; } set { tag = value; } } + + private long clock = 0; + public long Clock { get { return clock; } } + + /// + /// Add new samples to this signal. + /// + /// + /// + public void AddSamples(long Clock, double[] Samples) + { + lock (Lock) + { + samples.AddRange(Samples); + clock = Clock; + } + } + + /// + /// Truncate samples older than NewCount. + /// + /// + public void Truncate(int NewCount) + { + lock (Lock) + { + if (samples.Count > NewCount) + { + int remove = samples.Count - NewCount; + samples.RemoveRange(0, remove); + } + } + } + + public void Clear() { lock (Lock) { samples.Clear(); clock = 0; } } + + public int Count { get { return samples.Count; } } + public double this[int i] + { + get + { + if (0 <= i && i < samples.Count) + return samples[(int)i]; + else + return double.NaN; + } + } + + public object Lock { get { return samples; } } + + // IEnumerable interface + IEnumerator IEnumerable.GetEnumerator() { return samples.GetEnumerator(); } + IEnumerator IEnumerable.GetEnumerator() { return samples.GetEnumerator(); } + } + + /// Component to mark nodes for probing. + /// + class Probe : OneTerminal + { + protected EdgeType color; + public EdgeType Color { get { return color; } set { color = value; } } + + public Signal Signal = null; + + private double[] buffer = null; + public double[] Buffer { get { return buffer; } } + + public double[] AllocBuffer(int Samples) + { + if (buffer == null || buffer.Length != Samples) + buffer = new double[Samples]; + return buffer; + } + + private Probe() : this(EdgeType.Red) { } + public Probe(EdgeType Color) { color = Color; } + + public override void Analyze(Analysis Mna) { } + + public override void LayoutSymbol(SymbolLayout Sym) + { + Coord w = new Coord(0, 0); + Sym.AddTerminal(Terminal, w); + + Coord dw = new Coord(1, 1); + Coord pw = new Coord(dw.y, -dw.x); + + w += dw * 10; + Sym.AddWire(Terminal, w); + + Sym.AddLine(color, w - pw * 4, w + pw * 4); + Sym.AddLoop(color, + w + pw * 2, + w + pw * 2 + dw * 10, + w + dw * 12, + w - pw * 2 + dw * 10, + w - pw * 2); + + if (ConnectedTo != null) + Sym.DrawText(() => V.ToString(), new Point(0, 6), Alignment.Far, Alignment.Near); + } + } + + public class SymbolControl : ElementControl + { + + private bool showText = true; + + protected SymbolLayout layout; + + public SymbolControl(Symbol S) : base(S) + { + layout = Component.LayoutSymbol(); + } + public SymbolControl(Component C) : this(new Symbol(C)) { } + + public Symbol Symbol { get { return (Symbol)element; } } + public Component Component { get { return Symbol.Component; } } + + } + + public class ElementControl + { + + protected bool showTerminals = true; + public bool ShowTerminals { get { return showTerminals; } set { showTerminals = value;} } + + private List selectedChanged = new List(); + public event EventHandler SelectedChanged { add { selectedChanged.Add(value); } remove { selectedChanged.Remove(value); } } + + private bool selected = false; + public bool Selected + { + get { return selected; } + set + { + if (selected == value) return; + + selected = value; + foreach (EventHandler i in selectedChanged) + i(this, new EventArgs()); + } + } + + private bool highlighted = false; + public bool Highlighted + { + get { return highlighted; } + set + { + if (highlighted == value) return; + highlighted = value; + } + } + + protected Element element; + public Element Element { get { return element; } } + + protected ElementControl(Element E) + { + element = E; + element.Tag = this; + } + + public static ElementControl New(Element E) + { + if (E is Symbol symbol) + return new SymbolControl(symbol); + else + throw new NotImplementedException(); + } + + + public static double TerminalSize = 2.0; + public static double EdgeThickness = 1.0; + } +} \ No newline at end of file diff --git a/Circuit/packages-microsoft-prod.deb b/Circuit/packages-microsoft-prod.deb new file mode 100644 index 0000000000000000000000000000000000000000..92839d8e0aa2c71cb5ab58370d8eb1ddb542c2bb GIT binary patch literal 3124 zcmai#X*AT28pp?9g{hD&VJulf4MuiTj9rp7WoQ{&maJnD=BpZv#_6cqkd_~ZYK_rdZqa$tK;4__b8hcdn>ADIV_WdF7wCa&8ef1jkJ`Ezyj|jGl-;FO?o61{V8r zX?%Y&;Q2$Mf2In{qC>|fC`ud$7wl>b7VP!Lv$C>1>-GllUyo>Ibh+3Max5QSFQw&& z_#RuIx}&bYuRG>aezi(K~CWj(^47+Tg66 z+F7_i-&6xy=M5<$p?L-U`Ui)Z%B^A>LZb#P%-5z=rSMbM(Eh5q;TkC}lP}OkZdBP8 zHN%vIoNL}{MHY?#dOaJV=;dL+`;j3K-S!dK31efl$-d-bW1?g~z8`rJ>VWyHdB*r# z$vb2Iz=X4^&0EN!db!LDs~meG+!Inuj|Q@o>l7YSP) zZ!cvUU&ReLi-Jp(o2C?poy%aHc?R$J3;P^AuIJ&OJZG8fjj=VgGu3_EIT}o3E-pu6 zOtqv@WD~h{uSUO2vx;W!i?&`K0wHtybmgm=Ui16 z$1?R?k1Q{*$wl!Q@YU0P*Tef@+#^20D#Xpb!rBD+xQ^ZoC?%7}OX7Pa?OsQKrF5j? zTR0Y6sijAxEb_W9)+l7Lm@z)N-ea-cG-yW7_z-hImJlR#UJQ0li{3 z7C4qm8J7i5?7CjeWFMJr&20`CNwD|iManpuGq{G6V?}mr9^Xcjn~`+r2rR*7T`Ol#mwpt2WS*cf`b;_#rJ`w* zP1!irVan|uX?CoIzTOE?qS7XhQLg3g8|8(=A)P<9NAk}^N6%PYqPY9jl>xz9Ej0Zj zC4ebMV*|hl+p!oi8-5Bsm{N#$=3V=2lAHJ`A`*Vgi_!_QLWmly*rCj?$;~DgC!GGN z;5eK(+;_pOb6U))s!*R(!=$PcKjS>Ss(9Xh;Xx=ozJS3f?|yHRi~O9gUf}iV&VgM8 zeBLgZ(wIa~o;@>w5ggExy%0-$ah6ck`at32VY+f-y|?sBv^cK50*+gH6@=g^SJ{@w zmrEM+UYkq~@`g7?cKF^{3`?u@npiuv(=K&&@`vBpZb!GV|0>VDhk|OY`tiIlyUv(1 zqIwUhLR~8fY#>Px2<(9JMg0eszd`tSyoD(#{TsRe!^;d7Q_fqw#3V1?nI9iTkg`^8 zy2b@zJM^{OtOLx?N#6+&b;qX}B+imT&draBA`L6dX?? zqQFn5@TMCA3YLbhNZ@!R^)>Av6P^-P>Mv{cw}Csf;5O^05hHB|s+QLX?xZ0vx9@2^ zW(3QGUP4URg%ra%pL1;H{+r>}u*;Y9wAu0bk~Q_-Pz(?ihTgZAsL^(B4WX+hYhv|H z3p9+-vVV=>{3Nn&AK>1#h0y2Ta{ZvOAK73AYbiVi3wI)t z(O!FJC(hkma&5@2rHUvQUeynjs_XJy?%aJwU$5P1dcm?4IV zYWF^7|2lSJ6;h;DpRyO9xz=XrC3z&97aC)G;GrU!M(* zwd0I?VH2dl+|nzw6Qu(xQ>(!hp2)P8G(ld5g;UF@ZJBgh`uU4RYNL%|8_;njH`=Qt zZSq;D`Z~hPd3%u5a61lYzxhhP*6?IyWsz$G#QKN-uNNl9sE}-#pxKlek;cmI#vsRK z6J{JuWBl}$hAbYu{N04!7h%aQN!C|38Px3qYM9UQn64I?*qZZ1Mf5|0oQkQ-sf%TK z_=3}Qk=tER&bWd{C0W++OVHG@jO_AcADbWz>r@U5X1gn^m}%mg8n;ME-8*T>&-zpo z78!pL@=d>;=Y|v$oFsBBNUPHX|AofA|Hf6Q)awSYT#Hny=D?_bd`?fJP4Q(H^rUf| zlAEY)iV(=Q~e0ABI5M5e#s&8*jnR}mS-4#T2ngTigR%0 z$9DT>>V7stbD-a(JM?qtNT*7RS$z-9l|1bf8qbTs-QB@6e0`dp1EkS~ERRpOh?Xx7 z{DAS%G%q@91Nu3C(KVvi%(0A!%42%|p^*1EM@b^B07T24UdWr7&jKYk$s3j5!xBzy z0uXKV{zyuw7kxBze8;))D2?=Ne_vFd0R9ZH=CC~d!mbRXmM~C$z|5ohB_RU3yj~t$ z9qeyd^PN>v^XniSm#1?jWbM?Wg;AGXocdlcP&j;t!wqo}sxSknp4&UocNemv>Wj%{suwtxBNa&~(OUu2s2 zd*P)`&wJ|Sgu}_VK(w%ASm+^*SraHj4@&^GzW@+)7dx|sJ!U2>PX!V{B|!KVw)|)^ z$mP!Ah-tn`#^BHIJPbBP0N0p9S;%2pk%Kfl0BKX`iz&`YgLz6LGR`7U1Ir*V(U?oS zoC|URJO_rS9#9pO1?;UQnTgI-EE~#t-~9O^w1-aF2+ivz`T~2?t&{FHPb^w9mIdoSy^>KhBFBGR z&4x*LRuP4(4InMA`v&Aofr!>w4aPCs(nao<(7j^43t2B`DrToXj0X{Z9ey=T688`+ z%7nUwV^Rxn1^vcxFUb5;w2b*%&`ZloB8pcYpSiNv=uv#=dDuL9u<)(7UAIC;h>X}d u;!A3L=GUXj-soB-hg_L(J9@JwdXxX|uLIEkQK)hX7DV Date: Fri, 12 Mar 2021 13:33:50 +0100 Subject: [PATCH 02/42] created SimulateSimple --- Circuit/SimulateSimple.cs | 170 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 Circuit/SimulateSimple.cs diff --git a/Circuit/SimulateSimple.cs b/Circuit/SimulateSimple.cs new file mode 100644 index 00000000..09932cd0 --- /dev/null +++ b/Circuit/SimulateSimple.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Collections.ObjectModel; + + +namespace Circuit{ + public class SimulateSimple + { + + + public static void Main(string[] args) { + SimpleSim sim = new SimpleSim(args[0]); + } + + + } + + public class SimpleSim{ + + private object sync = new object(); + protected Circuit circuit = null; + protected Simulation simulation = null; + + protected ObservableCollection _inputChannels = new ObservableCollection(); + protected ObservableCollection _outputChannels = new ObservableCollection(); + public ObservableCollection InputChannels { get { return _inputChannels; } } + public ObservableCollection OutputChannels { get { return _outputChannels; } } + private double inputGain = 1.0; + private double outputGain = 1.0; + private Dictionary inputs = new Dictionary(); + private List probes = new List(); + private int clock = -1; + protected int oversample = 8; + protected int iterations = 8; + + protected Stream stream = null; + protected System.Timers.Timer timer; + + readonly List inputBuffers = new List(); + readonly List outputBuffers = new List(); + + public SimpleSim(string fileName){ + Schematic schema = Schematic.Load(fileName); + Circuit circuit = schema.Build(); + + NullStream stream = new NullStream(ProcessSamples); + ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (stream.SampleRate * oversample); + TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); + TransientSolution.Solve(circuit.Analyze(), h); + + simulation = new Simulation(solution); + } + + private void ProcessSamples(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) + { + // The time covered by these samples. + double timespan = Count / Rate; + + // Apply input gain. + for (int i = 0; i < In.Length; ++i) + { + System.Console.WriteLine("in:"); + System.Console.WriteLine(In[i].Amplify(1)); + } + + // Run the simulation. + lock (sync) + { + if (simulation != null) + RunSimulation(Count, In, Out, Rate); + else + foreach (SampleBuffer i in Out) + i.Clear(); + } + + for (int i = 0; i < In.Length; ++i) + { + System.Console.WriteLine("out:"); + System.Console.WriteLine(Out[i].Amplify(1)); + } + } + + private void RunSimulation(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) + { + try + { + // If the sample rate changed, we need to kill the simulation and let the foreground rebuild it. + if (Rate != (double)simulation.SampleRate) + { + simulation = null; + Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); + RebuildSolutionThread.Start(); + return; + } + + inputBuffers.Clear(); + foreach (Channel i in inputs.Values) + { + if (i is InputChannel input) + inputBuffers.Add(In[input.Index].Samples); + else if (i is SignalChannel channel) + inputBuffers.Add(channel.Buffer(Count, simulation.Time, simulation.TimeStep)); + } + + outputBuffers.Clear(); + foreach (Probe i in probes) + outputBuffers.Add(i.AllocBuffer(Count)); + for (int i = 0; i < Out.Length; ++i) + outputBuffers.Add(Out[i].Samples); + + // Process the samples! + simulation.Run(Count, inputBuffers, outputBuffers); + + foreach (Probe i in probes) + i.Signal.AddSamples(clock, i.Buffer); + } + catch (SimulationDiverged Ex) + { + Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); + // If the simulation diverged more than one second ago, reset it and hope it doesn't happen again. + simulation = null; + if ((double)Ex.At > Rate) + RebuildSolutionThread.Start(); + foreach (SampleBuffer i in Out) + i.Clear(); + } + catch (Exception Ex) + { + // If there was a more serious error, kill the simulation so the user can fix it. + simulation = null; + foreach (SampleBuffer i in Out) + i.Clear(); + } + } + + private void RebuildSolution() + { + Stream stream = new NullStream(ProcessSamples); + + lock (sync) + { + simulation = null; + + try + { + ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (stream.SampleRate * oversample); + TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); + + simulation = new Simulation(solution) + { + Input = inputs.Keys.ToArray(), + Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(), + Oversample = oversample, + Iterations = iterations, + }; + System.Console.WriteLine("Solution was rebuilt"); + + } + catch (Exception Ex) + { + System.Console.WriteLine(Ex); + } + + } + } + } + +} \ No newline at end of file From b795a951a5a7495cc39ddf8ac2988ac9d2664c4d Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Tue, 16 Mar 2021 07:43:02 +0100 Subject: [PATCH 03/42] implemented simulate simple --- Circuit/Circuit.csproj | 2 +- Circuit/Simulate.cs | 1093 ------------------------------------- Circuit/SimulateSimple.cs | 167 +----- 3 files changed, 12 insertions(+), 1250 deletions(-) delete mode 100644 Circuit/Simulate.cs diff --git a/Circuit/Circuit.csproj b/Circuit/Circuit.csproj index e617a4a7..a8f84b5f 100644 --- a/Circuit/Circuit.csproj +++ b/Circuit/Circuit.csproj @@ -7,7 +7,7 @@ Copyright © 2020 1.0.0.0 1.0.0.0 - Circuit.Simulate + Circuit.SimulateSimple true diff --git a/Circuit/Simulate.cs b/Circuit/Simulate.cs deleted file mode 100644 index 89e0b07e..00000000 --- a/Circuit/Simulate.cs +++ /dev/null @@ -1,1093 +0,0 @@ -using ComputerAlgebra.LinqCompiler; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Collections.ObjectModel; -using System.ComponentModel; -using MathNet.Numerics.IntegralTransforms; -using System.Numerics; -using System.Text; -using System.Collections; - -namespace Circuit -{ - - class NullDevice : Device - { - private Guid classid; - - public override Stream Open(Stream.SampleHandler Callback, Channel[] Input, Channel[] Output) - { - return new NullStream(ProcessSamples); - } - - private void ProcessSamples(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) - { - // The time covered by these samples. - double timespan = Count / Rate; - - // Apply input gain. - for (int i = 0; i < In.Length; ++i) - { - Channel ch = InputChannels[i]; - ; - System.Console.WriteLine(In[i].Amplify(1)); - } - - - // Apply output gain. - for (int i = 0; i < Out.Length; ++i) - { - Channel ch = OutputChannels[i]; - System.Console.WriteLine(Out[i].Amplify(1)); - } - } - } - public class Simulate - { - public static void Main(string[] args) { - Schematic schema = Schematic.Load(args[0]); - Device device = new NullDevice(); - InputChannel input = new InputChannel(1); - Channel[] Inputs = {}; - Channel[] Outputs = {new OutputChannel(1)}; - LiveSimulation simulation = new LiveSimulation(schema, device, Inputs, Outputs); - } - } - - - public class LiveSimulation - { - private object sync = new object(); - protected Circuit circuit = null; - protected Simulation simulation = null; - - protected ObservableCollection _inputChannels = new ObservableCollection(); - protected ObservableCollection _outputChannels = new ObservableCollection(); - public ObservableCollection InputChannels { get { return _inputChannels; } } - public ObservableCollection OutputChannels { get { return _outputChannels; } } - private double inputGain = 1.0; - private double outputGain = 1.0; - private Dictionary inputs = new Dictionary(); - private List probes = new List(); - private int clock = -1; - protected int oversample = 8; - protected Stream stream = null; - protected System.Timers.Timer timer; - public LiveSimulation(Schematic Simulate, Device Device, Channel[] Inputs, Channel[] Outputs) - { - System.Console.WriteLine("Creating simulation"); - try - { - // Make a clone of the schematic so we can mess with it. - Schematic clone = Schematic.Deserialize(Simulate.Serialize()); - clone.Elements.ItemAdded += OnElementAdded; - clone.Elements.ItemRemoved += OnElementRemoved; - - // Build the circuit from the schematic. - circuit = clone.Build(); - System.Console.WriteLine("Cloned circuit"); - - // Create the input and output controls. - IEnumerable components = circuit.Components; - - // Create audio input channels. - for (int i = 0; i < Inputs.Length; ++i) - InputChannels.Add(new InputChannel(i) { Name = Inputs[i].Name }); - - System.Console.WriteLine("Added inputs"); - - ComputerAlgebra.Expression speakers = 0; - - foreach (Component i in components) - { - Symbol S = i.Tag as Symbol; - if (S == null) - continue; - - SymbolControl tag = (SymbolControl)S.Tag; - if (tag == null) - continue; - if (i is Speaker output) - speakers += output.Out; - - // Create input controls. - if (i is Input input) - { - - ComputerAlgebra.Expression In = input.In; - inputs[In] = new SignalChannel(0); - - } - } - - System.Console.WriteLine("Added components"); - - // Create audio output channels. - for (int i = 0; i < Outputs.Length; ++i) - { - OutputChannel c = new OutputChannel(i) { Name = Outputs[i].Name, Signal = speakers }; - c.PropertyChanged += (o, e) => { if (e.PropertyName == "Signal") RebuildSolution(); }; - OutputChannels.Add(c); - } - - System.Console.WriteLine("Beginning audio processing"); - - // Begin audio processing. - if (Inputs.Any() || Outputs.Any()) - stream = Device.Open(ProcessSamples, Inputs, Outputs); - else - stream = new NullStream(ProcessSamples); - - System.Console.WriteLine("Rebuilding solution"); - - }catch (Exception Ex){ - System.Console.WriteLine(Ex); - } - } - - - private void OnElementAdded(object sender, ElementEventArgs e) - { - if (e.Element is Symbol && ((Symbol)e.Element).Component is Probe) - { - Probe probe = (Probe)((Symbol)e.Element).Component; - probe.Signal = new Signal() - { - Name = probe.V.ToString(), - }; - lock (sync) - { - probes.Add(probe); - if (simulation != null) - simulation.Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(); - } - } - } - - private void OnElementRemoved(object sender, ElementEventArgs e) - { - if (e.Element is Symbol && ((Symbol)e.Element).Component is Probe) - { - Probe probe = (Probe)((Symbol)e.Element).Component; - lock (sync) - { - probes.Remove(probe); - if (simulation != null) - simulation.Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(); - } - } - } - - - protected int iterations = 8; - /// - /// Max iterations for numerical algorithms. - /// - public int Iterations - { - get { return iterations; } - set { iterations = value; RebuildSolution(); NotifyChanged(nameof(Iterations)); } - } - - private void NotifyChanged(string p) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p)); - } - public event PropertyChangedEventHandler PropertyChanged; - - public int Oversample - { - get { return oversample; } - set { oversample = value; RebuildSolution(); NotifyChanged(nameof(Oversample)); } - } - - private void ProcessSamples(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) - { - // The time covered by these samples. - double timespan = Count / Rate; - - // Apply input gain. - for (int i = 0; i < In.Length; ++i) - { - Channel ch = InputChannels[i]; - double peak = In[i].Amplify(inputGain); - ch.SampleSignalLevel(peak, timespan); - } - - // Run the simulation. - lock (sync) - { - if (simulation != null) - RunSimulation(Count, In, Out, Rate); - else - foreach (SampleBuffer i in Out) - i.Clear(); - } - - // Apply output gain. - for (int i = 0; i < Out.Length; ++i) - { - Channel ch = OutputChannels[i]; - double peak = Out[i].Amplify(outputGain); - ch.SampleSignalLevel(peak, timespan); - } - } - - // These lists only ever grow, but they should never contain more than 10s of items. - readonly List inputBuffers = new List(); - readonly List outputBuffers = new List(); - private void RunSimulation(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) - { - try - { - // If the sample rate changed, we need to kill the simulation and let the foreground rebuild it. - if (Rate != (double)simulation.SampleRate) - { - simulation = null; - Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); - RebuildSolutionThread.Start(); - return; - } - - inputBuffers.Clear(); - foreach (Channel i in inputs.Values) - { - if (i is InputChannel input) - inputBuffers.Add(In[input.Index].Samples); - else if (i is SignalChannel channel) - inputBuffers.Add(channel.Buffer(Count, simulation.Time, simulation.TimeStep)); - } - - outputBuffers.Clear(); - foreach (Probe i in probes) - outputBuffers.Add(i.AllocBuffer(Count)); - for (int i = 0; i < Out.Length; ++i) - outputBuffers.Add(Out[i].Samples); - - // Process the samples! - simulation.Run(Count, inputBuffers, outputBuffers); - - foreach (Probe i in probes) - i.Signal.AddSamples(clock, i.Buffer); - } - catch (SimulationDiverged Ex) - { - Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); - // If the simulation diverged more than one second ago, reset it and hope it doesn't happen again. - simulation = null; - if ((double)Ex.At > Rate) - RebuildSolutionThread.Start(); - foreach (SampleBuffer i in Out) - i.Clear(); - } - catch (Exception Ex) - { - // If there was a more serious error, kill the simulation so the user can fix it. - simulation = null; - foreach (SampleBuffer i in Out) - i.Clear(); - } - } - - private void RebuildSolution() - { - Stream stream = new NullStream(ProcessSamples); - - lock (sync) - { - simulation = null; - - try - { - ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (stream.SampleRate * Oversample); - TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); - - simulation = new Simulation(solution) - { - Input = inputs.Keys.ToArray(), - Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(), - Oversample = Oversample, - Iterations = Iterations, - }; - System.Console.WriteLine("Solution was rebuilt"); - - } - catch (Exception Ex) - { - System.Console.WriteLine(Ex); - } - - } - } - } - - /// - /// Base class for a running audio stream. - /// - public abstract class Stream - { - private Channel[] inputs, outputs; - - protected Stream(Channel[] Inputs, Channel[] Outputs) { inputs = Inputs; outputs = Outputs; } - - /// - /// Handler for accepting new samples in and writing output samples out. - /// - /// - public delegate void SampleHandler(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate); - - public Channel[] InputChannels { get { return inputs; } } - public Channel[] OutputChannels { get { return outputs; } } - - public abstract double SampleRate { get; } - - public abstract void Stop(); - } - - /// - /// Devices describe the supported audio stream properties. - /// - public abstract class Device - { - protected string name; - public string Name { get { return name; } } - - protected Channel[] inputs; - public Channel[] InputChannels { get { return inputs; } } - protected Channel[] outputs; - public Channel[] OutputChannels { get { return outputs; } } - - protected Device() { } - protected Device(string Name) { name = Name; } - - public abstract Stream Open(Stream.SampleHandler Callback, Channel[] Input, Channel[] Output); - } - - /// - /// This object defines a pinned sample array, suitable for sharing - /// with native code. - /// - public class SampleBuffer : IDisposable - { - /// - /// Number of samples contained in this buffer. - /// - public uint Count => (uint)Samples.Length; - - /// - /// Samples in this buffer. - /// - public double[] Samples { get; private set; } - private GCHandle pin; - - /// - /// Access samples of this buffer. - /// - /// - /// - public double this[int i] { get { return Samples[i]; } set { Samples[i] = value; } } - - /// - /// Pointer to raw samples in this buffer. - /// - public IntPtr Raw { get { return pin.AddrOfPinnedObject(); } } - - private object tag = null; - /// - /// User defined tag object. - /// - public object Tag { get { return tag; } set { tag = value; } } - - public SampleBuffer(int Count) - { - Samples = new double[Count]; - pin = GCHandle.Alloc(Samples, GCHandleType.Pinned); - } - - ~SampleBuffer() { Dispose(false); } - public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - private void Dispose(bool Disposing) - { - if (pin.IsAllocated) - pin.Free(); - - Samples = null; - } - - /// - /// Set this buffer to have the zero signal. - /// - public void Clear() - { - Util.ZeroMemory(Raw, Count * sizeof(double)); - } - - /// - /// Amplify the samples in this buffer. - /// - /// - public double Amplify(double Gain) - { - return Util.Amplify(Raw, Count, Gain); - } - } - - - public static class Util - { - // C# is great, but man generics suck compared to templates. - // TODO: simd? - public static unsafe void LEi16ToLEf64(IntPtr source, IntPtr destination, uint count) - { - short* from = (short*)source.ToPointer(); - double* to = (double*)destination.ToPointer(); - double scale = 1.0 / ((1 << 15) - 1); - for (int i = 0; i < count; i++) - to[i] = from[i] * scale; - } - public static unsafe void LEi32ToLEf64(IntPtr source, IntPtr destination, uint count) - { - int* from = (int*)source.ToPointer(); - double* to = (double*)destination.ToPointer(); - double scale = 1.0 / ((1L << 31) - 1); - for (int i = 0; i < count; i++) - to[i] = from[i] * scale; - } - public static unsafe void LEf32ToLEf64(IntPtr source, IntPtr destination, uint count) - { - float* from = (float*)source.ToPointer(); - double* to = (double*)destination.ToPointer(); - for (int i = 0; i < count; i++) - to[i] = from[i]; - } - - public static unsafe void LEf64ToLEi16(IntPtr source, IntPtr destination, uint count) - { - double* from = (double*)source.ToPointer(); - short* to = (short*)destination.ToPointer(); - double max = (1 << 15) - 1; - for (int i = 0; i < count; i++) - to[i] = (short)Math.Max(Math.Min(from[i] * max, max), -max); - } - public static unsafe void LEf64ToLEi32(IntPtr source, IntPtr destination, uint count) - { - double* from = (double*)source.ToPointer(); - int* to = (int*)destination.ToPointer(); - double max = (1L << 31) - 1; - for (int i = 0; i < count; i++) - to[i] = (int)Math.Max(Math.Min(from[i] * max, max), -max); - } - public static unsafe void LEf64ToLEf32(IntPtr source, IntPtr destination, uint count) - { - double* from = (double*)source.ToPointer(); - float* to = (float*)destination.ToPointer(); - for (int i = 0; i < count; i++) - to[i] = (float)from[i]; - } - - public static unsafe double Amplify(IntPtr Samples, uint count, double Gain) - { - double* s = (double*)Samples.ToPointer(); - double peak = 0.0; - for (int i = 0; i < count; i++) - { - s[i] = s[i] * Gain; - // TODO: Absolute value of s[i]? - peak = Math.Max(peak, s[i]); - } - return peak; - } - - public static unsafe void CopyMemory(IntPtr destination, IntPtr source, uint count) => Unsafe.CopyBlock(destination.ToPointer(), source.ToPointer(), count); - - public static unsafe void ZeroMemory(IntPtr startAddress, uint count) => Unsafe.InitBlockUnaligned(startAddress.ToPointer(), 0, count); - } - - public class NullStream : Stream - { - public override double SampleRate { get { return 48000; } } - - private readonly SampleHandler callback; - - private bool run = true; - private Thread thread; - - public NullStream(SampleHandler Callback) : base(new Channel[] { }, new Channel[] { }) - { - callback = Callback; - thread = new Thread(Proc); - thread.Start(); - } - - private void Proc() - { - SampleBuffer[] input = new SampleBuffer[] { }; - SampleBuffer[] output = new SampleBuffer[] { }; - - long samples = 0; - DateTime start = DateTime.Now; - while (run) - { - // Run at ~50 callbacks/second. This doesn't need to be super precise. In - // practice, Thread.Sleep is going to be +/- 10s of ms, but we'll still deliver - // the right number of samples on average. - Thread.Sleep(20); - double elapsed = (DateTime.Now - start).TotalSeconds; - int needed_samples = (int)(Math.Round(elapsed * SampleRate) - samples); - callback(needed_samples, input, output, SampleRate); - samples += needed_samples; - } - } - - public override void Stop() - { - run = false; - thread.Join(); - thread = null; - } - } - - public abstract class Channel : INotifyPropertyChanged - { - private string name = ""; - public string Name { get { return name; } set { name = value; NotifyChanged(nameof(Name)); } } - - - private double signalLevel = 0; - public double SignalLevel { get { return signalLevel; } } - /// - /// Update the signal level of this channel. - /// - /// - /// - public void SampleSignalLevel(double level, double time) - { - double a = Frequency.DecayRate(time, 0.25); - signalLevel = Math.Max(level, level * a + signalLevel * (1 - a)); - } - - public void ResetSignalLevel() { signalLevel = 0; } - - // INotifyPropertyChanged. - protected void NotifyChanged(string p) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p)); - } - public event PropertyChangedEventHandler PropertyChanged; - } - - /// - /// Channel of audio data. - /// - public class InputChannel : Channel - { - private int index = 0; - public int Index { get { return index; } } - - public InputChannel(int Index) { index = Index; } - } - - /// - /// Channel generated by a signal expression. - /// - public class SignalChannel : Channel - { - private Func signal; - - private double[] buffer = null; - public double[] Buffer(int Count, double t, double dt) - { - if (buffer == null || buffer.Length != Count) - buffer = new double[Count]; - for (int i = 0; i < Count; ++i, t += dt) - buffer[i] = signal(t); - return buffer; - } - - public SignalChannel(ComputerAlgebra.Expression Signal) - { - signal = Signal.Compile>(Component.t); - } - } - - /// - /// Output audio channel. - /// - public class OutputChannel : Channel - { - private int index = 0; - public int Index { get { return index; } } - - private ComputerAlgebra.Expression signal = 0; - public ComputerAlgebra.Expression Signal { get { return signal; } set { signal = value; NotifyChanged(nameof(Signal)); } } - - public OutputChannel(int Index) { index = Index; } - } - - public class Frequency - { - private static string[] Notes = { "C", "C\u266f", "D", "D\u266f", "E", "F", "F\u266f", "G", "G\u266f", "A", "A\u266f", "B" }; - public static string ToNote(double f, double A4) - { - // Halfsteps above C0 - double halfsteps = (Math.Log(f / A4, 2.0) + 5.0) * 12.0 - 3.0; - if (halfsteps < 0 || double.IsNaN(halfsteps) || double.IsInfinity(halfsteps)) - return ""; - - int note = (int)Math.Round(halfsteps) % 12; - int octave = (int)Math.Round(halfsteps) / 12; - int cents = (int)Math.Round((halfsteps - Math.Round(halfsteps)) * 100); - - StringBuilder sb = new StringBuilder(Notes[note]); - sb.Append(IntToSubscript(octave)); - sb.Append(' '); - if (cents >= 0) - sb.Append('+'); - sb.Append(cents); - sb.Append('\u00A2'); - return sb.ToString(); - } - - private static string IntToSubscript(int x) - { - string chars = x.ToString(); - - StringBuilder ret = new StringBuilder(); - foreach (char i in chars) - { - if (i == '-') - ret.Append((char)0x208B); - else - ret.Append((char)(0x2080 + i - '0')); - } - return ret.ToString(); - } - - public static double Estimate(double[] Samples, int Decimate, out double Phase) - { - Complex[] data = DecimateSignal(Samples, Decimate); - int N = data.Length; - Fourier.Forward(data); - // Zero the DC bin. - data[0] = 0.0; - - double f = 0.0; - double max = 0.0; - Phase = 0.0; - - // Find largest frequency in FFT. - for (int i = 1; i < N / 2 - 1; ++i) - { - double x; - Complex m = LogParabolaMax(data[i - 1], data[i], data[i + 1], out x); - - if (m.Magnitude > max) - { - max = m.Magnitude; - f = i + x; - Phase = m.Phase; - } - } - - // Check if this is a harmonic of another frequency (the fundamental frequency). - double f0 = f; - for (int h = 2; h < 5; ++h) - { - int i = (int)Math.Round(f / h); - if (i >= 1) - { - double x; - Complex m = LogParabolaMax(data[i - 1], data[i], data[i + 1], out x); - - if (m.Magnitude * 5.0 > max) - { - f0 = f / h; - Phase = m.Phase; - } - } - } - - return f0; - } - - private static double Hann(int i, int N) { return 0.5 * (1.0 - Math.Cos((2.0 * Math.PI * i) / (N - 1))); } - - // Fit parabola to 3 bins and find the maximum. - private static Complex LogParabolaMax(Complex A, Complex B, Complex C, out double x) - { - double a = A.Magnitude; - double b = B.Magnitude; - double c = C.Magnitude; - - if (b > a && b > c) - { - // Parabola fitting is more accurate in log magnitude. - a = Math.Log(a); - b = Math.Log(b); - c = Math.Log(c); - - // Maximum location. - x = (a - c) / (2.0 * (a - 2.0 * b + c)); - - // Maximum value. - return Complex.FromPolarCoordinates( - Math.Exp(b - x * (a - c) / 4.0), - (B - x * (A - C) / 4.0).Phase); - } - else - { - x = 0.0; - return B; - } - } - - private static Complex[] DecimateSignal(double[] Block, int Decimate) - { - int N = Block.Length / Decimate; - Complex[] data = new Complex[N]; - - // Decimate input audio with low pass filter. - for (int i = 0; i < N; ++i) - { - double v = 0.0; - for (int j = 0; j < Decimate; ++j) - v += Block[i * Decimate + j]; - data[i] = new Complex(v * Hann(i, N), 0.0); - } - return data; - } - - /// - /// Get the parameter for a first-order IIR filter. - /// - /// The time between steps. - /// The time to decay by half. - public static double DecayRate(double timestep, double halflife) - { - return Math.Exp(timestep / halflife * Math.Log(0.5)); - } - } - - public enum ScopeMode - { - Oscilloscope, - Spectrogram, - } - - public class SignalEventArgs : EventArgs - { - private Signal e; - public Signal Signal { get { return e; } } - - public SignalEventArgs(Signal E) { e = E; } - } - - public class TickEventArgs : EventArgs - { - private long clock; - public long Clock { get { return Clock; } } - - public TickEventArgs(long Clock) { clock = Clock; } - } - - /// - /// Collection of Signals. - /// - public class SignalCollection : IEnumerable, IEnumerable - { - protected List x = new List(); - - public Signal this[int index] { get { lock (x) return x[index]; } } - - public delegate void SignalEventHandler(object sender, SignalEventArgs e); - - private List itemAdded = new List(); - protected void OnItemAdded(SignalEventArgs e) { foreach (SignalEventHandler i in itemAdded) i(this, e); } - public event SignalEventHandler ItemAdded - { - add { itemAdded.Add(value); } - remove { itemAdded.Remove(value); } - } - - private List itemRemoved = new List(); - protected void OnItemRemoved(SignalEventArgs e) { foreach (SignalEventHandler i in itemRemoved) i(this, e); } - public event SignalEventHandler ItemRemoved - { - add { itemRemoved.Add(value); } - remove { itemRemoved.Remove(value); } - } - - private long clock = 0; - public long Clock { get { return clock; } } - - private double sampleRate = 1; - public double SampleRate { get { return sampleRate; } } - - public void TickClock(int SampleCount, double SampleRate) - { - sampleRate = SampleRate; - - int truncate = (int)sampleRate / 4; - - // Remove the signals that we didn't get data for. - ForEach(i => - { - if (i.Clock < clock) - i.Clear(); - else - i.Truncate(truncate); - }); - - clock += SampleCount; - } - - // ICollection - public int Count { get { lock (x) return x.Count; } } - public void Add(Signal item) - { - lock (x) x.Add(item); - OnItemAdded(new SignalEventArgs(item)); - } - public void AddRange(IEnumerable items) - { - foreach (Signal i in items) - Add(i); - } - public void Clear() - { - Signal[] removed = x.ToArray(); - lock (x) x.Clear(); - - foreach (Signal i in removed) - OnItemRemoved(new SignalEventArgs(i)); - } - public bool Contains(Signal item) { lock (x) return x.Contains(item); } - public void CopyTo(Signal[] array, int arrayIndex) { lock (x) x.CopyTo(array, arrayIndex); } - public bool Remove(Signal item) - { - bool ret; - lock (x) ret = x.Remove(item); - if (ret) - OnItemRemoved(new SignalEventArgs(item)); - return ret; - } - public void RemoveRange(IEnumerable items) - { - foreach (Signal i in items) - Remove(i); - } - - /// - /// This is thread safe. - /// - /// - public void ForEach(Action f) - { - lock (x) foreach (Signal i in x) - f(i); - } - - // IEnumerable - public IEnumerator GetEnumerator() { return x.GetEnumerator(); } - - IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } - } - - public class Signal : IEnumerable - { - private List samples = new List(); - - private string name; - /// - /// Name of this signal. - /// - public string Name { get { return name; } set { name = value; } } - public override string ToString() { return name; } - - /// - /// Pen to draw this signal with. - /// - - private object tag; - public object Tag { get { return tag; } set { tag = value; } } - - private long clock = 0; - public long Clock { get { return clock; } } - - /// - /// Add new samples to this signal. - /// - /// - /// - public void AddSamples(long Clock, double[] Samples) - { - lock (Lock) - { - samples.AddRange(Samples); - clock = Clock; - } - } - - /// - /// Truncate samples older than NewCount. - /// - /// - public void Truncate(int NewCount) - { - lock (Lock) - { - if (samples.Count > NewCount) - { - int remove = samples.Count - NewCount; - samples.RemoveRange(0, remove); - } - } - } - - public void Clear() { lock (Lock) { samples.Clear(); clock = 0; } } - - public int Count { get { return samples.Count; } } - public double this[int i] - { - get - { - if (0 <= i && i < samples.Count) - return samples[(int)i]; - else - return double.NaN; - } - } - - public object Lock { get { return samples; } } - - // IEnumerable interface - IEnumerator IEnumerable.GetEnumerator() { return samples.GetEnumerator(); } - IEnumerator IEnumerable.GetEnumerator() { return samples.GetEnumerator(); } - } - - /// Component to mark nodes for probing. - /// - class Probe : OneTerminal - { - protected EdgeType color; - public EdgeType Color { get { return color; } set { color = value; } } - - public Signal Signal = null; - - private double[] buffer = null; - public double[] Buffer { get { return buffer; } } - - public double[] AllocBuffer(int Samples) - { - if (buffer == null || buffer.Length != Samples) - buffer = new double[Samples]; - return buffer; - } - - private Probe() : this(EdgeType.Red) { } - public Probe(EdgeType Color) { color = Color; } - - public override void Analyze(Analysis Mna) { } - - public override void LayoutSymbol(SymbolLayout Sym) - { - Coord w = new Coord(0, 0); - Sym.AddTerminal(Terminal, w); - - Coord dw = new Coord(1, 1); - Coord pw = new Coord(dw.y, -dw.x); - - w += dw * 10; - Sym.AddWire(Terminal, w); - - Sym.AddLine(color, w - pw * 4, w + pw * 4); - Sym.AddLoop(color, - w + pw * 2, - w + pw * 2 + dw * 10, - w + dw * 12, - w - pw * 2 + dw * 10, - w - pw * 2); - - if (ConnectedTo != null) - Sym.DrawText(() => V.ToString(), new Point(0, 6), Alignment.Far, Alignment.Near); - } - } - - public class SymbolControl : ElementControl - { - - private bool showText = true; - - protected SymbolLayout layout; - - public SymbolControl(Symbol S) : base(S) - { - layout = Component.LayoutSymbol(); - } - public SymbolControl(Component C) : this(new Symbol(C)) { } - - public Symbol Symbol { get { return (Symbol)element; } } - public Component Component { get { return Symbol.Component; } } - - } - - public class ElementControl - { - - protected bool showTerminals = true; - public bool ShowTerminals { get { return showTerminals; } set { showTerminals = value;} } - - private List selectedChanged = new List(); - public event EventHandler SelectedChanged { add { selectedChanged.Add(value); } remove { selectedChanged.Remove(value); } } - - private bool selected = false; - public bool Selected - { - get { return selected; } - set - { - if (selected == value) return; - - selected = value; - foreach (EventHandler i in selectedChanged) - i(this, new EventArgs()); - } - } - - private bool highlighted = false; - public bool Highlighted - { - get { return highlighted; } - set - { - if (highlighted == value) return; - highlighted = value; - } - } - - protected Element element; - public Element Element { get { return element; } } - - protected ElementControl(Element E) - { - element = E; - element.Tag = this; - } - - public static ElementControl New(Element E) - { - if (E is Symbol symbol) - return new SymbolControl(symbol); - else - throw new NotImplementedException(); - } - - - public static double TerminalSize = 2.0; - public static double EdgeThickness = 1.0; - } -} \ No newline at end of file diff --git a/Circuit/SimulateSimple.cs b/Circuit/SimulateSimple.cs index 09932cd0..e35acfc9 100644 --- a/Circuit/SimulateSimple.cs +++ b/Circuit/SimulateSimple.cs @@ -1,169 +1,24 @@ using System; +using System.Collections; using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Collections.ObjectModel; namespace Circuit{ public class SimulateSimple { - - public static void Main(string[] args) { - SimpleSim sim = new SimpleSim(args[0]); - } - - - } - - public class SimpleSim{ - - private object sync = new object(); - protected Circuit circuit = null; - protected Simulation simulation = null; - - protected ObservableCollection _inputChannels = new ObservableCollection(); - protected ObservableCollection _outputChannels = new ObservableCollection(); - public ObservableCollection InputChannels { get { return _inputChannels; } } - public ObservableCollection OutputChannels { get { return _outputChannels; } } - private double inputGain = 1.0; - private double outputGain = 1.0; - private Dictionary inputs = new Dictionary(); - private List probes = new List(); - private int clock = -1; - protected int oversample = 8; - protected int iterations = 8; - - protected Stream stream = null; - protected System.Timers.Timer timer; - - readonly List inputBuffers = new List(); - readonly List outputBuffers = new List(); - - public SimpleSim(string fileName){ - Schematic schema = Schematic.Load(fileName); + Schematic schema = Schematic.Load(args[0]); Circuit circuit = schema.Build(); - - NullStream stream = new NullStream(ProcessSamples); - ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (stream.SampleRate * oversample); + ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (48000 * 1); TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); - TransientSolution.Solve(circuit.Analyze(), h); - - simulation = new Simulation(solution); - } - - private void ProcessSamples(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) - { - // The time covered by these samples. - double timespan = Count / Rate; - - // Apply input gain. - for (int i = 0; i < In.Length; ++i) - { - System.Console.WriteLine("in:"); - System.Console.WriteLine(In[i].Amplify(1)); - } - - // Run the simulation. - lock (sync) - { - if (simulation != null) - RunSimulation(Count, In, Out, Rate); - else - foreach (SampleBuffer i in Out) - i.Clear(); - } - - for (int i = 0; i < In.Length; ++i) - { - System.Console.WriteLine("out:"); - System.Console.WriteLine(Out[i].Amplify(1)); - } - } - - private void RunSimulation(int Count, SampleBuffer[] In, SampleBuffer[] Out, double Rate) - { - try - { - // If the sample rate changed, we need to kill the simulation and let the foreground rebuild it. - if (Rate != (double)simulation.SampleRate) - { - simulation = null; - Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); - RebuildSolutionThread.Start(); - return; - } - - inputBuffers.Clear(); - foreach (Channel i in inputs.Values) - { - if (i is InputChannel input) - inputBuffers.Add(In[input.Index].Samples); - else if (i is SignalChannel channel) - inputBuffers.Add(channel.Buffer(Count, simulation.Time, simulation.TimeStep)); - } - - outputBuffers.Clear(); - foreach (Probe i in probes) - outputBuffers.Add(i.AllocBuffer(Count)); - for (int i = 0; i < Out.Length; ++i) - outputBuffers.Add(Out[i].Samples); - - // Process the samples! - simulation.Run(Count, inputBuffers, outputBuffers); - - foreach (Probe i in probes) - i.Signal.AddSamples(clock, i.Buffer); - } - catch (SimulationDiverged Ex) - { - Thread RebuildSolutionThread = new Thread(new ThreadStart(this.RebuildSolution)); - // If the simulation diverged more than one second ago, reset it and hope it doesn't happen again. - simulation = null; - if ((double)Ex.At > Rate) - RebuildSolutionThread.Start(); - foreach (SampleBuffer i in Out) - i.Clear(); - } - catch (Exception Ex) - { - // If there was a more serious error, kill the simulation so the user can fix it. - simulation = null; - foreach (SampleBuffer i in Out) - i.Clear(); - } - } - - private void RebuildSolution() - { - Stream stream = new NullStream(ProcessSamples); - - lock (sync) - { - simulation = null; - - try - { - ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (stream.SampleRate * oversample); - TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); - - simulation = new Simulation(solution) - { - Input = inputs.Keys.ToArray(), - Output = probes.Select(i => i.V).Concat(OutputChannels.Select(i => i.Signal)).ToArray(), - Oversample = oversample, - Iterations = iterations, - }; - System.Console.WriteLine("Solution was rebuilt"); - - } - catch (Exception Ex) - { - System.Console.WriteLine(Ex); - } - - } + System.Console.WriteLine(solution); + Simulation simulation = new Simulation(solution); + List input = new List(); + List output = new List(); + double[] firstInput = {2.0}; + input.Add(firstInput); + simulation.Run(1,input, output); + System.Console.WriteLine(simulation.Output); } } From 534c1f19fd1525ea2cea98e4ae23c8c8a7e74139 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Sun, 21 Mar 2021 21:09:31 +0100 Subject: [PATCH 04/42] managed to get some simulation working --- Circuit/SimulateSimple.cs | 49 ++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/Circuit/SimulateSimple.cs b/Circuit/SimulateSimple.cs index e35acfc9..164e68a0 100644 --- a/Circuit/SimulateSimple.cs +++ b/Circuit/SimulateSimple.cs @@ -1,6 +1,7 @@ -using System; -using System.Collections; using System.Collections.Generic; +using System.Linq; +using ComputerAlgebra; +using System; namespace Circuit{ @@ -12,13 +13,43 @@ public static void Main(string[] args) { ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (48000 * 1); TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); System.Console.WriteLine(solution); - Simulation simulation = new Simulation(solution); - List input = new List(); - List output = new List(); - double[] firstInput = {2.0}; - input.Add(firstInput); - simulation.Run(1,input, output); - System.Console.WriteLine(simulation.Output); + + + + Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault(); + + IEnumerable speakers = circuit.Components.OfType(); + + Expression outputExpression = 0; + + // Output is voltage drop across the speakers + foreach (Speaker speaker in speakers) + { + outputExpression += speaker.Out; + } + + Simulation simulation = new Simulation(solution) + { + Oversample = 1, + Iterations = 16, + Input = new[] { inputExpression }, + Output = new[] { outputExpression } + }; + + List ins = new List(); + List outs = new List(); + double[] micIn = {1}; + ins.Add(micIn); + double[] audioOut = {0}; + outs.Add(audioOut); + Random rand = new Random(); + for(int i = 1; i < 96000; i++){ + if(i%4 == 0){ + ins[0][0] = rand.NextDouble(); + } + simulation.Run(1,ins, outs); + } + System.Console.WriteLine(ins[0][0].ToString() + " " + outs[0][0]); } } From 49591b403846f07f3e53485942efca4271ca8648 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 00:13:24 +0200 Subject: [PATCH 05/42] Add headless Linux runner --- Circuit/AudioSimulationFactory.cs | 57 +++++ Circuit/Circuit.csproj | 9 +- Circuit/Circuit.sln | 17 -- Circuit/SimulateSimple.cs | 56 ----- Circuit/packages-microsoft-prod.deb | Bin 3124 -> 0 bytes LiveSPICE.Headless/LiveSPICE.Headless.csproj | 14 ++ LiveSPICE.Headless/Program.cs | 213 +++++++++++++++++++ LiveSPICE.Headless/WaveFile.cs | 159 ++++++++++++++ LiveSPICE.sln | 10 + LiveSPICEVst/SimulationProcessor.cs | 40 +--- README.md | 33 +++ 11 files changed, 492 insertions(+), 116 deletions(-) create mode 100644 Circuit/AudioSimulationFactory.cs delete mode 100644 Circuit/Circuit.sln delete mode 100644 Circuit/SimulateSimple.cs delete mode 100644 Circuit/packages-microsoft-prod.deb create mode 100644 LiveSPICE.Headless/LiveSPICE.Headless.csproj create mode 100644 LiveSPICE.Headless/Program.cs create mode 100644 LiveSPICE.Headless/WaveFile.cs diff --git a/Circuit/AudioSimulationFactory.cs b/Circuit/AudioSimulationFactory.cs new file mode 100644 index 00000000..6cb86c6b --- /dev/null +++ b/Circuit/AudioSimulationFactory.cs @@ -0,0 +1,57 @@ +using ComputerAlgebra; +using System; +using System.Linq; + +namespace Circuit +{ + public static class AudioSimulationFactory + { + public static TransientSolution Solve(Circuit circuit, double sampleRate, int oversample) + { + if (circuit == null) + throw new ArgumentNullException(nameof(circuit)); + if (sampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRate)); + if (oversample <= 0) + throw new ArgumentOutOfRangeException(nameof(oversample)); + + return TransientSolution.Solve(circuit.Analyze(), (Real)1 / (sampleRate * oversample)); + } + + public static Simulation Create(Circuit circuit, double sampleRate, int oversample, int iterations) + { + return Create(circuit, Solve(circuit, sampleRate, oversample), oversample, iterations); + } + + public static Simulation Create(Circuit circuit, TransientSolution solution, int oversample, int iterations) + { + if (circuit == null) + throw new ArgumentNullException(nameof(circuit)); + if (solution == null) + throw new ArgumentNullException(nameof(solution)); + if (oversample <= 0) + throw new ArgumentOutOfRangeException(nameof(oversample)); + if (iterations <= 0) + throw new ArgumentOutOfRangeException(nameof(iterations)); + + Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault(); + if (inputExpression == null) + throw new NotSupportedException("Circuit has no inputs."); + + Expression outputExpression = 0; + foreach (Speaker speaker in circuit.Components.OfType()) + outputExpression += speaker.Out; + + if (outputExpression.EqualsZero()) + throw new NotSupportedException("Circuit has no speaker outputs."); + + return new Simulation(solution) + { + Oversample = oversample, + Iterations = iterations, + Input = new[] { inputExpression }, + Output = new[] { outputExpression } + }; + } + } +} \ No newline at end of file diff --git a/Circuit/Circuit.csproj b/Circuit/Circuit.csproj index 5663f6e0..d0400e95 100644 --- a/Circuit/Circuit.csproj +++ b/Circuit/Circuit.csproj @@ -1,17 +1,12 @@  - net5.0 - Exe + netstandard2.0 + Library Circuit Circuit Copyright © 2020 1.0.0.0 1.0.0.0 - Circuit.SimulateSimple - true - - - ;DEBUG;$([MSBUILD]::GETTARGETFRAMEWORKIDENTIFIER('$(TARGETFRAMEWORK)'));$([MSBUILD]::GETTARGETFRAMEWORKIDENTIFIER('$(TARGETFRAMEWORK)'))$([MSBuild]::GetTargetFrameworkVersion('$(TargetFramework)', 2)); diff --git a/Circuit/Circuit.sln b/Circuit/Circuit.sln deleted file mode 100644 index 12c7114d..00000000 --- a/Circuit/Circuit.sln +++ /dev/null @@ -1,17 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Circuit", "Circuit.csproj", "{04D7B263-7882-4A12-9E74-07B39A89E1A7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {04D7B263-7882-4A12-9E74-07B39A89E1A7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Circuit/SimulateSimple.cs b/Circuit/SimulateSimple.cs deleted file mode 100644 index 164e68a0..00000000 --- a/Circuit/SimulateSimple.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using ComputerAlgebra; -using System; - - -namespace Circuit{ - public class SimulateSimple - { - public static void Main(string[] args) { - Schematic schema = Schematic.Load(args[0]); - Circuit circuit = schema.Build(); - ComputerAlgebra.Expression h = (ComputerAlgebra.Expression)1 / (48000 * 1); - TransientSolution solution = TransientSolution.Solve(circuit.Analyze(), h); - System.Console.WriteLine(solution); - - - - Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault(); - - IEnumerable speakers = circuit.Components.OfType(); - - Expression outputExpression = 0; - - // Output is voltage drop across the speakers - foreach (Speaker speaker in speakers) - { - outputExpression += speaker.Out; - } - - Simulation simulation = new Simulation(solution) - { - Oversample = 1, - Iterations = 16, - Input = new[] { inputExpression }, - Output = new[] { outputExpression } - }; - - List ins = new List(); - List outs = new List(); - double[] micIn = {1}; - ins.Add(micIn); - double[] audioOut = {0}; - outs.Add(audioOut); - Random rand = new Random(); - for(int i = 1; i < 96000; i++){ - if(i%4 == 0){ - ins[0][0] = rand.NextDouble(); - } - simulation.Run(1,ins, outs); - } - System.Console.WriteLine(ins[0][0].ToString() + " " + outs[0][0]); - } - } - -} \ No newline at end of file diff --git a/Circuit/packages-microsoft-prod.deb b/Circuit/packages-microsoft-prod.deb deleted file mode 100644 index 92839d8e0aa2c71cb5ab58370d8eb1ddb542c2bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3124 zcmai#X*AT28pp?9g{hD&VJulf4MuiTj9rp7WoQ{&maJnD=BpZv#_6cqkd_~ZYK_rdZqa$tK;4__b8hcdn>ADIV_WdF7wCa&8ef1jkJ`Ezyj|jGl-;FO?o61{V8r zX?%Y&;Q2$Mf2In{qC>|fC`ud$7wl>b7VP!Lv$C>1>-GllUyo>Ibh+3Max5QSFQw&& z_#RuIx}&bYuRG>aezi(K~CWj(^47+Tg66 z+F7_i-&6xy=M5<$p?L-U`Ui)Z%B^A>LZb#P%-5z=rSMbM(Eh5q;TkC}lP}OkZdBP8 zHN%vIoNL}{MHY?#dOaJV=;dL+`;j3K-S!dK31efl$-d-bW1?g~z8`rJ>VWyHdB*r# z$vb2Iz=X4^&0EN!db!LDs~meG+!Inuj|Q@o>l7YSP) zZ!cvUU&ReLi-Jp(o2C?poy%aHc?R$J3;P^AuIJ&OJZG8fjj=VgGu3_EIT}o3E-pu6 zOtqv@WD~h{uSUO2vx;W!i?&`K0wHtybmgm=Ui16 z$1?R?k1Q{*$wl!Q@YU0P*Tef@+#^20D#Xpb!rBD+xQ^ZoC?%7}OX7Pa?OsQKrF5j? zTR0Y6sijAxEb_W9)+l7Lm@z)N-ea-cG-yW7_z-hImJlR#UJQ0li{3 z7C4qm8J7i5?7CjeWFMJr&20`CNwD|iManpuGq{G6V?}mr9^Xcjn~`+r2rR*7T`Ol#mwpt2WS*cf`b;_#rJ`w* zP1!irVan|uX?CoIzTOE?qS7XhQLg3g8|8(=A)P<9NAk}^N6%PYqPY9jl>xz9Ej0Zj zC4ebMV*|hl+p!oi8-5Bsm{N#$=3V=2lAHJ`A`*Vgi_!_QLWmly*rCj?$;~DgC!GGN z;5eK(+;_pOb6U))s!*R(!=$PcKjS>Ss(9Xh;Xx=ozJS3f?|yHRi~O9gUf}iV&VgM8 zeBLgZ(wIa~o;@>w5ggExy%0-$ah6ck`at32VY+f-y|?sBv^cK50*+gH6@=g^SJ{@w zmrEM+UYkq~@`g7?cKF^{3`?u@npiuv(=K&&@`vBpZb!GV|0>VDhk|OY`tiIlyUv(1 zqIwUhLR~8fY#>Px2<(9JMg0eszd`tSyoD(#{TsRe!^;d7Q_fqw#3V1?nI9iTkg`^8 zy2b@zJM^{OtOLx?N#6+&b;qX}B+imT&draBA`L6dX?? zqQFn5@TMCA3YLbhNZ@!R^)>Av6P^-P>Mv{cw}Csf;5O^05hHB|s+QLX?xZ0vx9@2^ zW(3QGUP4URg%ra%pL1;H{+r>}u*;Y9wAu0bk~Q_-Pz(?ihTgZAsL^(B4WX+hYhv|H z3p9+-vVV=>{3Nn&AK>1#h0y2Ta{ZvOAK73AYbiVi3wI)t z(O!FJC(hkma&5@2rHUvQUeynjs_XJy?%aJwU$5P1dcm?4IV zYWF^7|2lSJ6;h;DpRyO9xz=XrC3z&97aC)G;GrU!M(* zwd0I?VH2dl+|nzw6Qu(xQ>(!hp2)P8G(ld5g;UF@ZJBgh`uU4RYNL%|8_;njH`=Qt zZSq;D`Z~hPd3%u5a61lYzxhhP*6?IyWsz$G#QKN-uNNl9sE}-#pxKlek;cmI#vsRK z6J{JuWBl}$hAbYu{N04!7h%aQN!C|38Px3qYM9UQn64I?*qZZ1Mf5|0oQkQ-sf%TK z_=3}Qk=tER&bWd{C0W++OVHG@jO_AcADbWz>r@U5X1gn^m}%mg8n;ME-8*T>&-zpo z78!pL@=d>;=Y|v$oFsBBNUPHX|AofA|Hf6Q)awSYT#Hny=D?_bd`?fJP4Q(H^rUf| zlAEY)iV(=Q~e0ABI5M5e#s&8*jnR}mS-4#T2ngTigR%0 z$9DT>>V7stbD-a(JM?qtNT*7RS$z-9l|1bf8qbTs-QB@6e0`dp1EkS~ERRpOh?Xx7 z{DAS%G%q@91Nu3C(KVvi%(0A!%42%|p^*1EM@b^B07T24UdWr7&jKYk$s3j5!xBzy z0uXKV{zyuw7kxBze8;))D2?=Ne_vFd0R9ZH=CC~d!mbRXmM~C$z|5ohB_RU3yj~t$ z9qeyd^PN>v^XniSm#1?jWbM?Wg;AGXocdlcP&j;t!wqo}sxSknp4&UocNemv>Wj%{suwtxBNa&~(OUu2s2 zd*P)`&wJ|Sgu}_VK(w%ASm+^*SraHj4@&^GzW@+)7dx|sJ!U2>PX!V{B|!KVw)|)^ z$mP!Ah-tn`#^BHIJPbBP0N0p9S;%2pk%Kfl0BKX`iz&`YgLz6LGR`7U1Ir*V(U?oS zoC|URJO_rS9#9pO1?;UQnTgI-EE~#t-~9O^w1-aF2+ivz`T~2?t&{FHPb^w9mIdoSy^>KhBFBGR z&4x*LRuP4(4InMA`v&Aofr!>w4aPCs(nao<(7j^43t2B`DrToXj0X{Z9ey=T688`+ z%7nUwV^Rxn1^vcxFUb5;w2b*%&`ZloB8pcYpSiNv=uv#=dDuL9u<)(7UAIC;h>X}d u;!A3L=GUXj-soB-hg_L(J9@JwdXxX|uLIEkQK)hX7DV + + net8.0 + Exe + LiveSPICE.Headless + LiveSPICE.Headless + Copyright © 2026 + 1.0.0.0 + 1.0.0.0 + + + + + \ No newline at end of file diff --git a/LiveSPICE.Headless/Program.cs b/LiveSPICE.Headless/Program.cs new file mode 100644 index 00000000..ef4e45cb --- /dev/null +++ b/LiveSPICE.Headless/Program.cs @@ -0,0 +1,213 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; + +namespace Circuit.Headless +{ + internal static class Program + { + private static int Main(string[] args) + { + try + { + Options options = Options.Parse(args); + WaveData inputWave = options.InputWavPath != null ? WaveFile.ReadMono(options.InputWavPath) : null; + + if (inputWave != null) + { + if (!options.HasExplicitSampleRate) + options.SetSampleRate(inputWave.SampleRate); + if (!options.HasExplicitSampleCount) + options.SetSampleCount(inputWave.Samples.Length); + } + + Schematic schematic = Schematic.Load(options.SchematicPath); + Circuit circuit = schematic.Build(); + Simulation simulation = AudioSimulationFactory.Create(circuit, options.SampleRate, options.Oversample, options.Iterations); + + double[] input = new double[options.BatchSize]; + double[] output = new double[options.BatchSize]; + double[] renderedOutput = options.OutputWavPath != null ? new double[options.SampleCount] : null; + + double min = double.PositiveInfinity; + double max = double.NegativeInfinity; + double sumSquares = 0; + double last = 0; + + int remaining = options.SampleCount; + int generated = 0; + while (remaining > 0) + { + int count = Math.Min(remaining, input.Length); + FillInput(input, count, generated, options, inputWave); + Array.Clear(output, 0, count); + + simulation.Run(count, new[] { input }, new[] { output }); + + for (int i = 0; i < count; i++) + { + double sample = output[i]; + min = Math.Min(min, sample); + max = Math.Max(max, sample); + sumSquares += sample * sample; + last = sample; + } + + if (renderedOutput != null) + Array.Copy(output, 0, renderedOutput, generated, count); + + remaining -= count; + generated += count; + } + + double rms = Math.Sqrt(sumSquares / options.SampleCount); + + if (renderedOutput != null) + WaveFile.WriteMono16(options.OutputWavPath, renderedOutput, (int)Math.Round(options.SampleRate)); + + Console.WriteLine("Headless simulation completed."); + Console.WriteLine($"Schematic: {Path.GetFileName(options.SchematicPath)}"); + Console.WriteLine($"Samples: {options.SampleCount} at {options.SampleRate.ToString(CultureInfo.InvariantCulture)} Hz"); + if (options.InputWavPath != null) + Console.WriteLine($"Input WAV: {options.InputWavPath}"); + if (options.OutputWavPath != null) + Console.WriteLine($"Output WAV: {options.OutputWavPath}"); + Console.WriteLine($"Output min={min.ToString("R", CultureInfo.InvariantCulture)} max={max.ToString("R", CultureInfo.InvariantCulture)} rms={rms.ToString("R", CultureInfo.InvariantCulture)} last={last.ToString("R", CultureInfo.InvariantCulture)}"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return 1; + } + } + + private static void FillInput(double[] input, int count, int offset, Options options, WaveData inputWave) + { + if (inputWave != null) + { + Array.Clear(input, 0, input.Length); + + int available = Math.Max(0, Math.Min(count, inputWave.Samples.Length - offset)); + if (available > 0) + Array.Copy(inputWave.Samples, offset, input, 0, available); + return; + } + + double radiansPerSample = 2 * Math.PI * options.Frequency / options.SampleRate; + for (int i = 0; i < count; i++) + input[i] = options.Amplitude * Math.Sin((offset + i) * radiansPerSample); + } + + private sealed class Options + { + public string SchematicPath { get; private set; } + public double SampleRate { get; private set; } = 48000; + public int Oversample { get; private set; } = 1; + public int Iterations { get; private set; } = 16; + public int SampleCount { get; private set; } = 48000; + public int BatchSize { get; private set; } = 256; + public double Frequency { get; private set; } = 440; + public double Amplitude { get; private set; } = 0.25; + public string InputWavPath { get; private set; } + public string OutputWavPath { get; private set; } + public bool HasExplicitSampleRate { get; private set; } + public bool HasExplicitSampleCount { get; private set; } + + public static Options Parse(string[] args) + { + if (args.Length == 0) + throw new ArgumentException("Usage: LiveSPICE.Headless [--input-wav path] [--output-wav path] [--sample-rate hz] [--oversample n] [--iterations n] [--samples n] [--batch-size n] [--frequency hz] [--amplitude value]"); + + Options options = new Options + { + SchematicPath = Path.GetFullPath(args[0]) + }; + + for (int i = 1; i < args.Length; i += 2) + { + if (i + 1 >= args.Length) + throw new ArgumentException($"Missing value for '{args[i]}'."); + + string value = args[i + 1]; + switch (args[i]) + { + case "--input-wav": + options.InputWavPath = Path.GetFullPath(value); + break; + case "--output-wav": + options.OutputWavPath = Path.GetFullPath(value); + break; + case "--sample-rate": + options.SampleRate = ParseDouble(args[i], value); + options.HasExplicitSampleRate = true; + break; + case "--oversample": + options.Oversample = ParseInt(args[i], value); + break; + case "--iterations": + options.Iterations = ParseInt(args[i], value); + break; + case "--samples": + options.SampleCount = ParseInt(args[i], value); + options.HasExplicitSampleCount = true; + break; + case "--batch-size": + options.BatchSize = ParseInt(args[i], value); + break; + case "--frequency": + options.Frequency = ParseDouble(args[i], value); + break; + case "--amplitude": + options.Amplitude = ParseDouble(args[i], value); + break; + default: + throw new ArgumentException($"Unknown option '{args[i]}'."); + } + } + + if (!File.Exists(options.SchematicPath)) + throw new FileNotFoundException("Schematic file not found.", options.SchematicPath); + if (options.InputWavPath != null && !File.Exists(options.InputWavPath)) + throw new FileNotFoundException("Input WAV file not found.", options.InputWavPath); + if (options.SampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(options.SampleRate)); + if (options.Oversample <= 0) + throw new ArgumentOutOfRangeException(nameof(options.Oversample)); + if (options.Iterations <= 0) + throw new ArgumentOutOfRangeException(nameof(options.Iterations)); + if (options.SampleCount <= 0) + throw new ArgumentOutOfRangeException(nameof(options.SampleCount)); + if (options.BatchSize <= 0) + throw new ArgumentOutOfRangeException(nameof(options.BatchSize)); + + return options; + } + + private static int ParseInt(string option, string value) + { + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) + ? parsed + : throw new ArgumentException($"Invalid integer value '{value}' for '{option}'."); + } + + private static double ParseDouble(string option, string value) + { + return double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : throw new ArgumentException($"Invalid numeric value '{value}' for '{option}'."); + } + + public void SetSampleRate(double sampleRate) + { + SampleRate = sampleRate; + } + + public void SetSampleCount(int sampleCount) + { + SampleCount = sampleCount; + } + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Headless/WaveFile.cs b/LiveSPICE.Headless/WaveFile.cs new file mode 100644 index 00000000..0b1e202c --- /dev/null +++ b/LiveSPICE.Headless/WaveFile.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Text; + +namespace Circuit.Headless +{ + internal sealed class WaveData + { + public WaveData(double[] samples, int sampleRate) + { + Samples = samples ?? throw new ArgumentNullException(nameof(samples)); + SampleRate = sampleRate; + } + + public double[] Samples { get; } + + public int SampleRate { get; } + } + + internal static class WaveFile + { + public static WaveData ReadMono(string path) + { + using FileStream stream = File.OpenRead(path); + using BinaryReader reader = new BinaryReader(stream, Encoding.ASCII, leaveOpen: false); + + if (ReadFourCc(reader) != "RIFF") + throw new InvalidDataException("WAV file is missing a RIFF header."); + + reader.ReadInt32(); + + if (ReadFourCc(reader) != "WAVE") + throw new InvalidDataException("File is not a WAVE container."); + + ushort audioFormat = 0; + ushort channels = 0; + int sampleRate = 0; + ushort bitsPerSample = 0; + byte[] data = null; + + while (reader.BaseStream.Position + 8 <= reader.BaseStream.Length) + { + string chunkId = ReadFourCc(reader); + int chunkSize = reader.ReadInt32(); + long nextChunk = reader.BaseStream.Position + chunkSize + (chunkSize % 2); + + if (chunkId == "fmt ") + { + audioFormat = reader.ReadUInt16(); + channels = reader.ReadUInt16(); + sampleRate = reader.ReadInt32(); + reader.ReadInt32(); + reader.ReadUInt16(); + bitsPerSample = reader.ReadUInt16(); + } + else if (chunkId == "data") + { + data = reader.ReadBytes(chunkSize); + } + + reader.BaseStream.Position = nextChunk; + } + + if (channels == 0 || sampleRate <= 0 || bitsPerSample == 0 || data == null) + throw new InvalidDataException("WAV file is missing required format or data chunks."); + + int bytesPerSample = bitsPerSample / 8; + int frameSize = bytesPerSample * channels; + if (bytesPerSample == 0 || frameSize == 0 || data.Length % frameSize != 0) + throw new InvalidDataException("WAV file has an unsupported frame layout."); + + int frameCount = data.Length / frameSize; + double[] samples = new double[frameCount]; + + for (int frame = 0; frame < frameCount; frame++) + { + double mixed = 0; + for (int channel = 0; channel < channels; channel++) + { + int offset = frame * frameSize + channel * bytesPerSample; + mixed += DecodeSample(data, offset, audioFormat, bitsPerSample); + } + + samples[frame] = mixed / channels; + } + + return new WaveData(samples, sampleRate); + } + + public static void WriteMono16(string path, double[] samples, int sampleRate) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + + using FileStream stream = File.Create(path); + using BinaryWriter writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: false); + + const short channels = 1; + const short bitsPerSample = 16; + int bytesPerSample = bitsPerSample / 8; + int dataSize = samples.Length * bytesPerSample; + int byteRate = sampleRate * channels * bytesPerSample; + short blockAlign = (short)(channels * bytesPerSample); + + WriteFourCc(writer, "RIFF"); + writer.Write(36 + dataSize); + WriteFourCc(writer, "WAVE"); + + WriteFourCc(writer, "fmt "); + writer.Write(16); + writer.Write((short)1); + writer.Write(channels); + writer.Write(sampleRate); + writer.Write(byteRate); + writer.Write(blockAlign); + writer.Write(bitsPerSample); + + WriteFourCc(writer, "data"); + writer.Write(dataSize); + + foreach (double sample in samples) + { + double clipped = Math.Max(-1.0, Math.Min(1.0, sample)); + short pcm = (short)Math.Round(clipped * short.MaxValue); + writer.Write(pcm); + } + } + + private static double DecodeSample(byte[] data, int offset, ushort audioFormat, ushort bitsPerSample) + { + return (audioFormat, bitsPerSample) switch + { + (1, 8) => (data[offset] - 128) / 128.0, + (1, 16) => BitConverter.ToInt16(data, offset) / 32768.0, + (1, 24) => ReadInt24(data, offset) / 8388608.0, + (1, 32) => BitConverter.ToInt32(data, offset) / 2147483648.0, + (3, 32) => BitConverter.ToSingle(data, offset), + _ => throw new NotSupportedException($"Unsupported WAV format: audioFormat={audioFormat}, bitsPerSample={bitsPerSample}.") + }; + } + + private static int ReadInt24(byte[] data, int offset) + { + int value = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16); + if ((value & 0x00800000) != 0) + value |= unchecked((int)0xFF000000); + return value; + } + + private static string ReadFourCc(BinaryReader reader) + { + return Encoding.ASCII.GetString(reader.ReadBytes(4)); + } + + private static void WriteFourCc(BinaryWriter writer, string fourCc) + { + writer.Write(Encoding.ASCII.GetBytes(fourCc)); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.sln b/LiveSPICE.sln index 6bf97481..da7a9739 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -36,6 +36,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .github\workflows\test.yml = .github\workflows\test.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{FF178A26-25B0-462A-9927-4FD42C710136}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -90,6 +92,14 @@ Global {ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Release|Any CPU.Build.0 = Release|Any CPU + {51F8BCEC-5C29-46BA-8448-06B516715840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51F8BCEC-5C29-46BA-8448-06B516715840}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51F8BCEC-5C29-46BA-8448-06B516715840}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51F8BCEC-5C29-46BA-8448-06B516715840}.Release|Any CPU.Build.0 = Release|Any CPU + {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LiveSPICEVst/SimulationProcessor.cs b/LiveSPICEVst/SimulationProcessor.cs index 03d56442..74e8c81f 100644 --- a/LiveSPICEVst/SimulationProcessor.cs +++ b/LiveSPICEVst/SimulationProcessor.cs @@ -272,8 +272,7 @@ void UpdateSimulation(bool rebuild) { try { - Analysis analysis = circuit.Analyze(); - TransientSolution ts = TransientSolution.Solve(analysis, (Real)1 / (sampleRate * oversample)); + TransientSolution ts = AudioSimulationFactory.Solve(circuit, sampleRate, oversample); lock (sync) { @@ -281,45 +280,14 @@ void UpdateSimulation(bool rebuild) { if (rebuild) { - Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault(); - - if (inputExpression == null) - { - simulationUpdateException = new NotSupportedException("Circuit has no inputs."); - } - else - { - IEnumerable speakers = circuit.Components.OfType(); - - Expression outputExpression = 0; - - // Output is voltage drop across the speakers - foreach (Speaker speaker in speakers) - { - outputExpression += speaker.Out; - } - - if (outputExpression.EqualsZero()) - { - simulationUpdateException = new NotSupportedException("Circuit has no speaker outputs."); - } - else - { - simulation = new Simulation(ts) - { - Oversample = oversample, - Iterations = iterations, - Input = new[] { inputExpression }, - Output = new[] { outputExpression } - }; - } - } + simulation = AudioSimulationFactory.Create(circuit, ts, oversample, iterations); } else { simulation.Solution = ts; - clock = id; } + + clock = id; } } } diff --git a/README.md b/README.md index c2a894db..6aee021b 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,36 @@ To clone the LiveSPICE repo, run the following commands: ```bash git clone --recursive https://github.com/dsharlet/LiveSPICE.git LiveSPICE ``` + +If you cloned without `--recursive`, initialize the submodules before building: + +```bash +git submodule update --init --recursive +``` + +Headless Linux +-------------- + +The `LiveSPICE.Headless` project provides a cross-platform console host for the simulation core. It reuses the same input/output wiring logic as the VST path, while the WPF UI and VST projects remain Windows-only. + +To build the headless runner: + +```bash +dotnet build LiveSPICE.Headless/LiveSPICE.Headless.csproj +``` + +To run a simple Linux smoke test: + +```bash +dotnet run --project LiveSPICE.Headless/LiveSPICE.Headless.csproj "Tests/Circuits/Passive 1stOrder Highpass RC.schx" +``` + +To process a real WAV input through an example pedal schematic: + +```bash +dotnet run --project LiveSPICE.Headless/LiveSPICE.Headless.csproj "Tests/Examples/MXR Distortion +.schx" \ + --input-wav "/path/to/input.wav" \ + --output-wav "/path/to/output.wav" +``` + +The runner accepts optional overrides for `--input-wav`, `--output-wav`, `--sample-rate`, `--oversample`, `--iterations`, `--samples`, `--batch-size`, `--frequency`, and `--amplitude`. From 90eb8b4e5298bd3d3009f07854c4e569ceab303c Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 19:36:17 +0200 Subject: [PATCH 06/42] Add Avalonia GUI port checkpoint --- GUI_PORT_PLAN.md | 189 +++++ LiveSPICE.Avalonia/App.cs | 21 + LiveSPICE.Avalonia/AppSettings.cs | 69 ++ LiveSPICE.Avalonia/AudioConfigWindow.cs | 137 ++++ LiveSPICE.Avalonia/ConfirmWindow.cs | 61 ++ LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj | 21 + LiveSPICE.Avalonia/MainWindow.cs | 800 +++++++++++++++++++ LiveSPICE.Avalonia/Program.cs | 22 + LiveSPICE.Avalonia/PropertyInspector.cs | 146 ++++ LiveSPICE.Avalonia/SavePromptWindow.cs | 69 ++ LiveSPICE.Avalonia/SchematicCanvas.cs | 637 +++++++++++++++ LiveSPICE.Avalonia/SchematicDocument.cs | 272 +++++++ LiveSPICE.Avalonia/WaveformWindow.cs | 207 +++++ LiveSPICE.sln | 6 + 14 files changed, 2657 insertions(+) create mode 100644 GUI_PORT_PLAN.md create mode 100644 LiveSPICE.Avalonia/App.cs create mode 100644 LiveSPICE.Avalonia/AppSettings.cs create mode 100644 LiveSPICE.Avalonia/AudioConfigWindow.cs create mode 100644 LiveSPICE.Avalonia/ConfirmWindow.cs create mode 100644 LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj create mode 100644 LiveSPICE.Avalonia/MainWindow.cs create mode 100644 LiveSPICE.Avalonia/Program.cs create mode 100644 LiveSPICE.Avalonia/PropertyInspector.cs create mode 100644 LiveSPICE.Avalonia/SavePromptWindow.cs create mode 100644 LiveSPICE.Avalonia/SchematicCanvas.cs create mode 100644 LiveSPICE.Avalonia/SchematicDocument.cs create mode 100644 LiveSPICE.Avalonia/WaveformWindow.cs diff --git a/GUI_PORT_PLAN.md b/GUI_PORT_PLAN.md new file mode 100644 index 00000000..62a99a5b --- /dev/null +++ b/GUI_PORT_PLAN.md @@ -0,0 +1,189 @@ +# LiveSPICE Cross-Platform GUI Port Plan + +## Current state + +The local `linux` branch already has a working non-Windows simulation path through `LiveSPICE.Headless`. That branch builds a console runner around the shared `Circuit` library and `Circuit/AudioSimulationFactory.cs`, and the README documents Linux smoke tests and WAV processing. + +The remaining portability gap is the GUI. The standalone editor is a WPF Windows desktop app in `LiveSPICE/LiveSPICE.csproj`, and the VST UI is also WPF-based in `LiveSPICEVst/LiveSPICEVst.csproj` through `AudioPlugSharpWPF`. Both target `net*-windows`, enable WPF/Windows Forms, and depend on Windows-only UI concepts such as `Microsoft.Win32` dialogs, WPF commands, WPF drawing, AvalonDock, and WinMM/ASIO-backed audio configuration. + +## Branch strategy + +Create a second branch from the local Linux branch so the headless Linux work stays intact and the GUI port can move independently: + +```bash +git switch linux +git pull --ff-only origin linux +git switch -c linux-gui-port +``` + +Before running those commands locally, review the current dirty worktree. At the time this plan was written, `.gitignore` was modified and `.codacy/`, `PR_DESCRIPTION.md`, and `out/` were untracked. Either commit, stash, or intentionally carry those changes before switching branches. + +## Recommended UI approach + +Use Avalonia as the default open-source port target for the standalone GUI. + +Avalonia is the closest fit because it keeps a XAML/C# mental model, supports Windows, macOS, and Linux, and has direct replacements for many WPF primitives used by LiveSPICE: windows, menus, command bindings, layout panels, data binding, custom controls, pointer input, drawing contexts, file pickers, and theming. + +Do not start with Electron unless the goal changes to a web-first shell. The existing editor is deeply tied to C# circuit objects, custom schematic rendering, and direct manipulation of schematic elements, so an Electron port would add an interprocess API and rewrite most UI logic instead of adapting it. + +Treat Avalonia XPF as an optional commercial shortcut, not the default plan. It may run more existing WPF XAML with fewer edits, but it adds licensing and still leaves platform-specific APIs, WPF-only dependencies, and audio/plugin details to solve. + +## Architecture target + +Keep the existing simulation core and serialization shared: + +- `Circuit` remains the cross-platform schematic, component, solver, and simulation library. +- `ComputerAlgebra` and `Util` remain shared support libraries. +- `LiveSPICE.Headless` remains the Linux command-line validation target. +- A new Avalonia app, tentatively `LiveSPICE.Avalonia`, hosts the cross-platform editor UI. +- The existing WPF `LiveSPICE` app stays buildable on Windows until the Avalonia app reaches feature parity. + +Avoid trying to retarget `LiveSPICE/LiveSPICE.csproj` in place at first. A sibling project makes it possible to port control by control while preserving the Windows app as a reference implementation. + +## Porting phases + +### Phase 1: Establish the Avalonia shell + +Create `LiveSPICE.Avalonia` targeting `net8.0` or newer without a `-windows` target framework. Add it to `LiveSPICE.sln` and reference `Circuit`, `ComputerAlgebra`, and `Util`. + +Build the first shell with: + +- main window +- menu bar and toolbar +- status bar +- open/save file pickers for `.schx` +- single-document schematic viewer +- basic settings path under the user's platform-appropriate application data directory + +Defer docking, MRU polish, audio config, and simulation UI until a schematic can be loaded and rendered. + +### Phase 2: Port schematic rendering + +Port `SchematicControls` concepts to Avalonia rather than trying to reuse WPF controls directly. + +The core work is replacing WPF rendering types in `SchematicControls`: + +- `Control.OnRender(DrawingContext)` becomes Avalonia custom control rendering. +- `System.Windows.Point`, `Vector`, `Matrix`, `Rect`, `Size` become Avalonia equivalents. +- WPF `Pen`, `Brushes`, `FormattedText`, geometry, and text APIs become Avalonia drawing APIs. +- Tooltips and hit testing need Avalonia pointer/event equivalents. + +Start with read-only rendering of symbols and wires. Use the existing `Circuit.SymbolLayout` data as the model, because it is already UI-framework neutral. Once rendering is correct, add selection highlighting, terminal tooltips, and zoom/pan. + +### Phase 3: Port schematic editing + +Port `SchematicControl`, `SchematicViewer`, and `SchematicEditor` behavior into Avalonia equivalents. + +Primary features: + +- grid snapping and coordinate conversion +- selection rectangle and multi-select +- move, wire, symbol, and probe tools +- clipboard copy/paste using XML serialization +- undo/redo through `EditStack` +- save/save-as and external modification detection +- component library insertion + +Keep edit operations model-driven. The existing `AddElements`, `RemoveElements`, `PropertyEdit`, and schematic serialization should continue to be the behavioral source of truth. + +### Phase 4: Replace WPF-only desktop dependencies + +Replace AvalonDock with a simpler first-pass layout: left component list, right property panel, central tabbed documents. Add a richer docking library only after the editor is functional. + +Replace `DotNetProjects.Extended.Wpf.Toolkit` property grid usage with either: + +- a small LiveSPICE-specific property editor generated from browsable component properties, or +- an Avalonia-compatible property grid package if one proves mature enough. + +Prefer the small custom property editor initially. LiveSPICE mostly needs predictable editing of component values, not a general-purpose WPF toolkit clone. + +### Phase 5: Audio on Linux and macOS + +For the standalone GUI, keep audio driver selection behind the existing `Audio.Driver` abstraction. The current GUI references `Asio` and `WaveAudio`; `WaveAudio` is WinMM-only and `Asio` is Windows-oriented. + +Implement new drivers as separate assemblies so `Audio.Driver.Drivers` can discover them like the existing drivers: + +- `JackAudio` for Linux JACK, using a maintained .NET binding or a small native interop layer. +- `CoreAudio` for macOS only after the GUI can load, edit, and render schematics. + +Initial Linux GUI milestones can run without real-time audio by reusing the headless WAV path. Real-time JACK should be a later milestone because it has native library, callback-thread, buffer-size, and packaging concerns. + +### Phase 6: Plugin UI strategy + +Handle the VST UI separately from the standalone editor. + +AudioPlugSharp itself is intended to support non-Windows audio/plugin targets, but this repository's plugin UI currently inherits from `AudioPluginWPF` and references `AudioPlugSharpWPF`. Public AudioPlugSharp docs emphasize built-in WPF UI support, so cross-platform plugin UI support needs verification before committing to an implementation. + +Investigate in this order: + +1. Check whether current AudioPlugSharp has a non-WPF UI base class or host-embeddable native view API. +2. Build a minimal no-editor plugin on Linux/macOS using the existing `SimulationProcessor` logic. +3. Build a tiny cross-platform plugin editor proof of concept before porting LiveSPICE's plugin UI. +4. If AudioPlugSharp cannot host Avalonia directly, keep the plugin editor Windows-only for the first GUI branch and ship the standalone Avalonia editor plus headless/audio processing on Linux. + +The plugin UI is much smaller than the full desktop app, but it has harder host-embedding constraints. Do not let it block the standalone GUI port. + +## Milestones and validation + +### Milestone A: Branch and baseline + +- Create `linux-gui-port` from `linux`. +- Confirm `dotnet build Circuit/Circuit.csproj` passes. +- Confirm `dotnet build LiveSPICE.Headless/LiveSPICE.Headless.csproj` passes. +- Run the README headless smoke test against `Tests/Circuits/Passive 1stOrder Highpass RC.schx`. + +### Milestone B: Avalonia app opens a schematic + +- Create `LiveSPICE.Avalonia`. +- Load a `.schx` file. +- Display schematic metadata or a placeholder document tab. +- Save settings without Windows registry or WPF settings dependencies. + +### Milestone C: Read-only schematic renderer + +- Render wires, symbols, text, terminals, and schematic grid. +- Validate against known examples under `Tests/Examples`. +- Add screenshot comparison fixtures if practical. + +### Milestone D: Basic editor parity + +- Select, move, add, delete, undo, redo, copy, paste, save, and save-as. +- Component library and property editor are usable. +- Existing `.schx` files round-trip without serialization changes. + +### Milestone E: Simulation workflow + +- Run simulation from the Avalonia UI using `AudioSimulationFactory`. +- Display probe/scope output or an initial non-real-time render result. +- Keep Linux validation available without requiring JACK. + +### Milestone F: Real-time audio + +- Add JACK driver assembly for Linux. +- Validate callback stability, buffer sizes, sample rate selection, xrun behavior, and clean shutdown. +- Add CoreAudio driver or document that macOS support currently depends on plugin-host processing. + +### Milestone G: Plugin investigation + +- Prove whether AudioPlugSharp can host a non-WPF editor on Linux/macOS. +- Decide between Avalonia plugin UI, no-editor plugin, or Windows-only plugin UI for the first release. + +## Technical risks + +- Custom drawing is the largest standalone GUI task. `SymbolControl` and `WireControl` depend directly on WPF drawing APIs. +- Docking and property grid packages are WPF-specific and should be replaced, not ported first. +- File dialogs, clipboard, tooltips, command routing, keyboard modifiers, and mouse capture all need Avalonia-specific implementations. +- Real-time audio should not run on UI abstractions. Keep audio callback code isolated from Avalonia dispatching. +- Plugin UI portability may be constrained by the plugin host API more than by Avalonia itself. +- Packaging needs per-platform handling for native audio libraries, app icons, file associations, and macOS signing/notarization. + +## First implementation checklist + +1. Create `linux-gui-port` from `linux` after cleaning or stashing unrelated worktree changes. +2. Add `LiveSPICE.Avalonia` with a minimal window and solution entry. +3. Add a tiny `SchematicDocumentViewModel` that loads `Circuit.Schematic` from disk. +4. Port read-only drawing into a new Avalonia schematic canvas. +5. Add open-file flow and zoom/pan. +6. Add focused editor tools and property editing. +7. Add non-real-time simulation validation before JACK/CoreAudio. +8. Investigate AudioPlugSharp non-WPF editor support in a separate proof-of-concept branch or folder. \ No newline at end of file diff --git a/LiveSPICE.Avalonia/App.cs b/LiveSPICE.Avalonia/App.cs new file mode 100644 index 00000000..a4621f43 --- /dev/null +++ b/LiveSPICE.Avalonia/App.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Themes.Fluent; + +namespace LiveSPICE.Avalonia; + +public sealed class App : Application +{ + public override void Initialize() + { + Styles.Add(new FluentTheme()); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/AppSettings.cs b/LiveSPICE.Avalonia/AppSettings.cs new file mode 100644 index 00000000..cec2273b --- /dev/null +++ b/LiveSPICE.Avalonia/AppSettings.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace LiveSPICE.Avalonia; + +public sealed class AppSettings +{ + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions { WriteIndented = true }; + + public List RecentFiles { get; set; } = new List(); + + public double WindowWidth { get; set; } = 1200; + + public double WindowHeight { get; set; } = 800; + + public string AudioDriver { get; set; } = string.Empty; + + public string AudioDevice { get; set; } = string.Empty; + + public List AudioInputs { get; set; } = new List(); + + public List AudioOutputs { get; set; } = new List(); + + public static string SettingsPath + { + get + { + string root = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return Path.Combine(root, "LiveSPICE", "avalonia-settings.json"); + } + } + + public static AppSettings Load() + { + try + { + if (File.Exists(SettingsPath)) + return JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) ?? new AppSettings(); + } + catch + { + } + + return new AppSettings(); + } + + public void Save() + { + Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath) ?? "."); + File.WriteAllText(SettingsPath, JsonSerializer.Serialize(this, Options)); + } + + public void MarkUsed(string path) + { + string fullPath = Path.GetFullPath(path); + RecentFiles.RemoveAll(i => string.Equals(Path.GetFullPath(i), fullPath, StringComparison.OrdinalIgnoreCase)); + RecentFiles.Insert(0, fullPath); + if (RecentFiles.Count > 20) + RecentFiles.RemoveRange(20, RecentFiles.Count - 20); + } + + public IEnumerable ExistingRecentFiles() + { + return RecentFiles.Where(File.Exists); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/AudioConfigWindow.cs b/LiveSPICE.Avalonia/AudioConfigWindow.cs new file mode 100644 index 00000000..5c32d3b2 --- /dev/null +++ b/LiveSPICE.Avalonia/AudioConfigWindow.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace LiveSPICE.Avalonia; + +public sealed class AudioConfigWindow : Window +{ + private readonly AppSettings settings; + private readonly ComboBox drivers = new ComboBox(); + private readonly ComboBox devices = new ComboBox(); + private readonly ListBox inputs = new ListBox { SelectionMode = SelectionMode.Multiple }; + private readonly ListBox outputs = new ListBox { SelectionMode = SelectionMode.Multiple }; + private readonly TextBlock status = new TextBlock(); + + public AudioConfigWindow(AppSettings settings) + { + this.settings = settings; + Title = "Audio Configuration"; + Width = 640; + Height = 500; + MinWidth = 520; + MinHeight = 360; + Content = BuildContent(); + PopulateDrivers(); + } + + private Control BuildContent() + { + DockPanel root = new DockPanel(); + + StackPanel buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(10) + }; + buttons.Children.Add(Button("Refresh", (_, _) => PopulateDrivers())); + buttons.Children.Add(Button("Save", (_, _) => SaveAndClose())); + DockPanel.SetDock(buttons, Dock.Bottom); + root.Children.Add(buttons); + + StackPanel panel = new StackPanel { Spacing = 8, Margin = new global::Avalonia.Thickness(12) }; + panel.Children.Add(Label("Driver")); + panel.Children.Add(drivers); + panel.Children.Add(Label("Device")); + panel.Children.Add(devices); + + Grid channels = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), ColumnSpacing = 10 }; + channels.Children.Add(ChannelPanel("Inputs", inputs)); + Border outputPanel = ChannelPanel("Outputs", outputs); + Grid.SetColumn(outputPanel, 1); + channels.Children.Add(outputPanel); + panel.Children.Add(channels); + panel.Children.Add(status); + + root.Children.Add(panel); + return root; + } + + private void PopulateDrivers() + { + List availableDrivers = Audio.Driver.Drivers.ToList(); + drivers.ItemsSource = availableDrivers; + drivers.SelectedItem = availableDrivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? availableDrivers.FirstOrDefault(); + drivers.SelectionChanged += (_, _) => PopulateDevices(); + PopulateDevices(); + status.Text = availableDrivers.Count == 0 ? "No audio drivers are available in this build." : "Ready"; + } + + private void PopulateDevices() + { + Audio.Driver? driver = drivers.SelectedItem as Audio.Driver; + List availableDevices = driver?.Devices.ToList() ?? new List(); + devices.ItemsSource = availableDevices; + devices.SelectedItem = availableDevices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? availableDevices.FirstOrDefault(); + devices.SelectionChanged += (_, _) => PopulateChannels(); + PopulateChannels(); + } + + private void PopulateChannels() + { + Audio.Device? device = devices.SelectedItem as Audio.Device; + inputs.ItemsSource = device?.InputChannels ?? Array.Empty(); + outputs.ItemsSource = device?.OutputChannels ?? Array.Empty(); + SelectSaved(inputs, settings.AudioInputs); + SelectSaved(outputs, settings.AudioOutputs); + } + + private void SaveAndClose() + { + settings.AudioDriver = (drivers.SelectedItem as Audio.Driver)?.Name ?? string.Empty; + settings.AudioDevice = (devices.SelectedItem as Audio.Device)?.Name ?? string.Empty; + settings.AudioInputs = inputs.SelectedItems?.OfType().Select(i => i.Name).ToList() ?? new List(); + settings.AudioOutputs = outputs.SelectedItems?.OfType().Select(i => i.Name).ToList() ?? new List(); + settings.Save(); + Close(); + } + + private static void SelectSaved(ListBox list, IEnumerable names) + { + if (list.SelectedItems == null) + return; + + list.SelectedItems.Clear(); + HashSet selected = names.ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (object? item in list.Items) + if (item is Audio.Channel channel && selected.Contains(channel.Name)) + list.SelectedItems.Add(item); + } + + private static Border ChannelPanel(string title, ListBox list) + { + DockPanel dock = new DockPanel(); + TextBlock header = new TextBlock { Text = title, FontWeight = FontWeight.Bold, Margin = new global::Avalonia.Thickness(4) }; + DockPanel.SetDock(header, Dock.Top); + dock.Children.Add(header); + dock.Children.Add(list); + return new Border { BorderBrush = Brushes.LightGray, BorderThickness = new global::Avalonia.Thickness(1), MinHeight = 220, Child = dock }; + } + + private static TextBlock Label(string text) + { + return new TextBlock { Text = text, FontWeight = FontWeight.Bold }; + } + + private static Button Button(string text, EventHandler click) + { + Button button = new Button { Content = text, MinWidth = 82 }; + button.Click += click; + return button; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/ConfirmWindow.cs b/LiveSPICE.Avalonia/ConfirmWindow.cs new file mode 100644 index 00000000..89755b35 --- /dev/null +++ b/LiveSPICE.Avalonia/ConfirmWindow.cs @@ -0,0 +1,61 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace LiveSPICE.Avalonia; + +public sealed class ConfirmWindow : Window +{ + private bool result; + + private ConfirmWindow(string title, string message) + { + Title = title; + Width = 460; + Height = 180; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + + TextBlock text = new TextBlock + { + Text = message, + TextWrapping = TextWrapping.Wrap, + Margin = new global::Avalonia.Thickness(16) + }; + + StackPanel buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(16) + }; + buttons.Children.Add(Button("Yes", true)); + buttons.Children.Add(Button("No", false)); + + DockPanel dock = new DockPanel(); + DockPanel.SetDock(buttons, Dock.Bottom); + dock.Children.Add(buttons); + dock.Children.Add(text); + Content = dock; + } + + public static async Task ShowAsync(Window owner, string title, string message) + { + ConfirmWindow window = new ConfirmWindow(title, message); + await window.ShowDialog(owner); + return window.result; + } + + private Button Button(string text, bool value) + { + Button button = new Button { Content = text, MinWidth = 80 }; + button.Click += (_, _) => + { + result = value; + Close(); + }; + return button; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj new file mode 100644 index 00000000..d8e9e662 --- /dev/null +++ b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj @@ -0,0 +1,21 @@ + + + WinExe + net8.0 + enable + enable + LiveSPICE.Avalonia + LiveSPICE.Avalonia + + + + + + + + + + + + + \ No newline at end of file diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs new file mode 100644 index 00000000..6b1904cc --- /dev/null +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -0,0 +1,800 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Circuit; +using Util; +using CircuitComponent = Circuit.Component; + +namespace LiveSPICE.Avalonia; + +public sealed class MainWindow : Window +{ + private static readonly Type[] CommonComponents = + { + typeof(Conductor), + typeof(Ground), + typeof(Rail), + typeof(Resistor), + typeof(Capacitor), + typeof(Inductor), + typeof(VoltageSource), + typeof(CurrentSource), + typeof(NamedWire), + typeof(Circuit.Label) + }; + + private readonly TabControl documents = new TabControl(); + private readonly ListBox componentList = new ListBox(); + private readonly PropertyInspector propertyInspector = new PropertyInspector(); + private readonly TextBlock status = new TextBlock { Text = "Ready", VerticalAlignment = VerticalAlignment.Center }; + private readonly AppSettings settings = AppSettings.Load(); + private readonly MenuItem recentFilesMenu = new MenuItem { Header = "Recent Files" }; + private bool suppressSelectionEvent; + private bool closeConfirmed; + + public MainWindow() + { + Title = "LiveSPICE Avalonia"; + Width = Math.Max(settings.WindowWidth, 800); + Height = Math.Max(settings.WindowHeight, 500); + MinWidth = 800; + MinHeight = 500; + KeyDown += OnKeyDown; + Activated += async (_, _) => await CheckExternalModificationsAsync(); + + DockPanel root = new DockPanel(); + + Menu menu = BuildMenu(); + DockPanel.SetDock(menu, Dock.Top); + root.Children.Add(menu); + + Control toolbar = BuildToolbar(); + DockPanel.SetDock(toolbar, Dock.Top); + root.Children.Add(toolbar); + + Border statusBar = new Border + { + Background = Brushes.WhiteSmoke, + BorderBrush = Brushes.LightGray, + BorderThickness = new global::Avalonia.Thickness(1, 0, 0, 0), + Padding = new global::Avalonia.Thickness(8, 4), + Child = status + }; + DockPanel.SetDock(statusBar, Dock.Bottom); + root.Children.Add(statusBar); + + Grid content = new Grid + { + ColumnDefinitions = new ColumnDefinitions("240,* ,300") + }; + + componentList.Margin = new global::Avalonia.Thickness(6); + componentList.DoubleTapped += (_, _) => ActivateSelectedComponent(); + componentList.SelectionChanged += (_, _) => ActivateSelectedComponent(); + PopulateComponents(); + + Border componentsPanel = Panel("Components", componentList); + Grid.SetColumn(componentsPanel, 0); + content.Children.Add(componentsPanel); + + documents.SelectionChanged += (_, _) => OnActiveDocumentChanged(); + Grid.SetColumn(documents, 1); + content.Children.Add(documents); + + propertyInspector.PropertyChangedByUser += action => + { + ActiveDocument?.Do(action); + ActiveCanvas?.InvalidateVisual(); + RefreshDocumentHeaders(); + }; + Border propertiesPanel = Panel("Properties", propertyInspector); + Grid.SetColumn(propertiesPanel, 2); + content.Children.Add(propertiesPanel); + + root.Children.Add(content); + Content = root; + } + + private SchematicDocument? ActiveDocument => ActiveTab?.Tag as SchematicDocument; + + private SchematicCanvas? ActiveCanvas => ActiveTab?.Content as SchematicCanvas; + + private TabItem? ActiveTab => documents.SelectedItem as TabItem; + + private Menu BuildMenu() + { + Menu menu = new Menu + { + ItemsSource = new[] + { + new MenuItem + { + Header = "_File", + ItemsSource = new Control[] + { + MenuItem("_New", (_, _) => NewDocument()), + MenuItem("_Open", async (_, _) => await OpenSchematicAsync()), + MenuItem("_Save", async (_, _) => await SaveActiveAsync()), + MenuItem("Save _As", async (_, _) => await SaveActiveAsAsync()), + MenuItem("Save A_ll", async (_, _) => await SaveAllAsync()), + new Separator(), + recentFilesMenu, + new Separator(), + MenuItem("_Close", async (_, _) => await CloseActiveAsync()), + MenuItem("E_xit", (_, _) => Close()) + } + }, + new MenuItem + { + Header = "_Edit", + ItemsSource = new Control[] + { + MenuItem("_Delete", (_, _) => ActiveCanvas?.DeleteSelection()), + MenuItem("_Undo", (_, _) => UndoActive()), + MenuItem("_Redo", (_, _) => RedoActive()), + new Separator(), + MenuItem("Cu_t", async (_, _) => await CutSelectionAsync()), + MenuItem("_Copy", async (_, _) => await CopySelectionAsync()), + MenuItem("_Paste", async (_, _) => await PasteSelectionAsync()), + MenuItem("Select _All", (_, _) => ActiveCanvas?.SelectAll()), + new Separator(), + MenuItem("Rotate Left", (_, _) => ActiveCanvas?.RotateSelection(1)), + MenuItem("Rotate Right", (_, _) => ActiveCanvas?.RotateSelection(-1)), + MenuItem("Flip", (_, _) => ActiveCanvas?.FlipSelection()) + } + }, + new MenuItem + { + Header = "_View", + ItemsSource = new Control[] + { + MenuItem("Zoom _In", (_, _) => ZoomActive(1.2)), + MenuItem("Zoom _Out", (_, _) => ZoomActive(1 / 1.2)), + MenuItem("Zoom _Fit", (_, _) => ActiveCanvas?.FitToView()) + } + }, + new MenuItem + { + Header = "_Simulate", + ItemsSource = new Control[] + { + MenuItem("Audio Settings", (_, _) => new AudioConfigWindow(settings).Show()), + MenuItem("Validate Build", (_, _) => ValidateActiveCircuit()), + MenuItem("Render Smoke", (_, _) => RenderSmoke()) + } + }, + new MenuItem + { + Header = "_About", + ItemsSource = new Control[] + { + MenuItem("About", (_, _) => status.Text = "LiveSPICE Avalonia GUI port") + } + } + } + }; + RefreshRecentFilesMenu(); + return menu; + } + + private static MenuItem MenuItem(string header, EventHandler click) + { + MenuItem item = new MenuItem { Header = header }; + item.Click += click; + return item; + } + + private Control BuildToolbar() + { + StackPanel toolbar = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new global::Avalonia.Thickness(8), + Children = + { + Button("New", (_, _) => NewDocument()), + Button("Open", async (_, _) => await OpenSchematicAsync()), + Button("Save", async (_, _) => await SaveActiveAsync()), + Button("Undo", (_, _) => UndoActive()), + Button("Redo", (_, _) => RedoActive()), + Button("Copy", async (_, _) => await CopySelectionAsync()), + Button("Paste", async (_, _) => await PasteSelectionAsync()), + Button("Delete", (_, _) => ActiveCanvas?.DeleteSelection()), + Button("Wire", (_, _) => BeginWireTool()), + Button("Run", (_, _) => RenderSmoke()), + Button("+", (_, _) => ZoomActive(1.2), 36), + Button("-", (_, _) => ZoomActive(1 / 1.2), 36), + Button("Fit", (_, _) => ActiveCanvas?.FitToView()) + } + }; + return toolbar; + } + + private static Button Button(string text, EventHandler click, double minWidth = 64) + { + Button button = new Button { Content = text, MinWidth = minWidth }; + button.Click += click; + return button; + } + + private static Border Panel(string title, Control content) + { + DockPanel dock = new DockPanel(); + TextBlock header = new TextBlock + { + Text = title, + FontWeight = FontWeight.Bold, + Margin = new global::Avalonia.Thickness(8, 8, 8, 4) + }; + DockPanel.SetDock(header, Dock.Top); + dock.Children.Add(header); + dock.Children.Add(content); + + return new Border + { + BorderBrush = Brushes.LightGray, + BorderThickness = new global::Avalonia.Thickness(1), + Child = dock + }; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + string? path = Environment.GetCommandLineArgs() + .Skip(1) + .FirstOrDefault(i => i.EndsWith(".schx", StringComparison.OrdinalIgnoreCase) && File.Exists(i)); + + if (path != null) + LoadSchematic(path); + else + NewDocument(); + + string? screenshotPath = Environment.GetEnvironmentVariable("LIVESPICE_SCREENSHOT"); + if (!string.IsNullOrWhiteSpace(screenshotPath)) + Dispatcher.UIThread.Post(() => SaveScreenshotAndExit(screenshotPath), DispatcherPriority.ApplicationIdle); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (!closeConfirmed && documents.Items.OfType().Any(i => (i.Tag as SchematicDocument)?.Dirty == true)) + { + e.Cancel = true; + Dispatcher.UIThread.Post(async () => + { + if (await CloseAllDocumentsAsync()) + { + closeConfirmed = true; + Close(); + } + }); + return; + } + + settings.WindowWidth = Width; + settings.WindowHeight = Height; + settings.Save(); + base.OnClosing(e); + } + + private async System.Threading.Tasks.Task OpenSchematicAsync() + { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open LiveSPICE schematic", + AllowMultiple = true, + FileTypeFilter = new[] + { + new FilePickerFileType("Circuit Schematics") { Patterns = new[] { "*.schx" } }, + FilePickerFileTypes.All + } + }); + + foreach (string path in files.Select(i => i.TryGetLocalPath()).Where(i => i != null).Cast()) + LoadSchematic(path); + } + + private void LoadSchematic(string path) + { + try + { + string fullPath = Path.GetFullPath(path); + TabItem? existing = documents.Items.OfType() + .FirstOrDefault(i => string.Equals(Path.GetFullPath(((SchematicDocument)i.Tag!).FilePath ?? string.Empty), fullPath, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + documents.SelectedItem = existing; + status.Text = $"Selected {Path.GetFileName(path)}"; + return; + } + + AddDocument(SchematicDocument.Open(path)); + settings.MarkUsed(path); + RefreshRecentFilesMenu(); + status.Text = $"Loaded {Path.GetFileName(path)}"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void NewDocument() + { + AddDocument(SchematicDocument.New()); + status.Text = "Created new schematic"; + } + + private void AddDocument(SchematicDocument document) + { + SchematicCanvas canvas = new SchematicCanvas { Document = document }; + canvas.SelectionChanged += OnCanvasSelectionChanged; + canvas.DocumentChanged += () => + { + RefreshDocumentHeaders(); + status.Text = document.Title; + }; + canvas.ContextMenu = BuildCanvasContextMenu(); + + TabItem tab = new TabItem + { + Header = document.Title, + Content = canvas, + Tag = document + }; + documents.Items.Add(tab); + documents.SelectedItem = tab; + canvas.FitToView(); + } + + private ContextMenu BuildCanvasContextMenu() + { + return new ContextMenu + { + ItemsSource = new Control[] + { + MenuItem("Undo", (_, _) => UndoActive()), + MenuItem("Redo", (_, _) => RedoActive()), + new Separator(), + MenuItem("Cut", async (_, _) => await CutSelectionAsync()), + MenuItem("Copy", async (_, _) => await CopySelectionAsync()), + MenuItem("Paste", async (_, _) => await PasteSelectionAsync()), + MenuItem("Delete", (_, _) => ActiveCanvas?.DeleteSelection()), + new Separator(), + MenuItem("Rotate Left", (_, _) => ActiveCanvas?.RotateSelection(1)), + MenuItem("Rotate Right", (_, _) => ActiveCanvas?.RotateSelection(-1)), + MenuItem("Flip", (_, _) => ActiveCanvas?.FlipSelection()), + MenuItem("Select All", (_, _) => ActiveCanvas?.SelectAll()) + } + }; + } + + private void RefreshDocumentHeaders() + { + foreach (TabItem item in documents.Items.OfType()) + if (item.Tag is SchematicDocument document) + item.Header = document.Title; + } + + private void OnActiveDocumentChanged() + { + if (suppressSelectionEvent) + return; + + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + if (ActiveDocument != null) + Title = $"LiveSPICE Avalonia - {ActiveDocument.Title}"; + } + + private void OnCanvasSelectionChanged() + { + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + } + + private void ZoomActive(double multiplier) + { + if (ActiveCanvas != null) + ActiveCanvas.Zoom *= multiplier; + } + + private async System.Threading.Tasks.Task CloseActiveAsync() + { + if (ActiveTab != null && await TryCloseTabAsync(ActiveTab)) + { + documents.Items.Remove(ActiveTab); + if (documents.Items.Count == 0) + NewDocument(); + } + } + + private async System.Threading.Tasks.Task CloseAllDocumentsAsync() + { + foreach (TabItem tab in documents.Items.OfType().ToList()) + { + documents.SelectedItem = tab; + if (!await TryCloseTabAsync(tab)) + return false; + documents.Items.Remove(tab); + } + + return true; + } + + private async System.Threading.Tasks.Task TryCloseTabAsync(TabItem tab) + { + if (tab.Tag is not SchematicDocument document || !document.Dirty) + return true; + + SaveChoice choice = await SavePromptWindow.ShowAsync(this, document.Title); + if (choice == SaveChoice.Cancel) + return false; + if (choice == SaveChoice.Discard) + return true; + + documents.SelectedItem = tab; + await SaveActiveAsync(); + return !document.Dirty; + } + + private async System.Threading.Tasks.Task CheckExternalModificationsAsync() + { + List modified = documents.Items.OfType() + .Where(i => (i.Tag as SchematicDocument)?.WasModifiedExternally() == true) + .ToList(); + if (modified.Count == 0) + return; + + string names = string.Join("\n", modified.Select(i => ((SchematicDocument)i.Tag!).FilePath)); + if (!await ConfirmWindow.ShowAsync(this, "Reload Modified Schematics", "Reload schematics modified outside LiveSPICE?\n\n" + names)) + return; + + foreach (TabItem tab in modified) + { + if (tab.Tag is not SchematicDocument document || document.FilePath == null) + continue; + + int index = documents.Items.IndexOf(tab); + documents.Items.Remove(tab); + SchematicDocument reloaded = SchematicDocument.Open(document.FilePath); + SchematicCanvas canvas = new SchematicCanvas { Document = reloaded }; + canvas.SelectionChanged += OnCanvasSelectionChanged; + canvas.DocumentChanged += () => + { + RefreshDocumentHeaders(); + status.Text = reloaded.Title; + }; + canvas.ContextMenu = BuildCanvasContextMenu(); + TabItem replacement = new TabItem { Header = reloaded.Title, Content = canvas, Tag = reloaded }; + documents.Items.Insert(index, replacement); + documents.SelectedItem = replacement; + canvas.FitToView(); + } + + RefreshDocumentHeaders(); + status.Text = "Reloaded externally modified schematics"; + } + + private async System.Threading.Tasks.Task SaveActiveAsync() + { + if (ActiveDocument == null) + return; + + if (ActiveDocument.FilePath == null) + await SaveActiveAsAsync(); + else + SaveDocument(ActiveDocument, ActiveDocument.FilePath); + } + + private async System.Threading.Tasks.Task SaveActiveAsAsync() + { + if (ActiveDocument == null) + return; + + IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save LiveSPICE schematic", + SuggestedFileName = ActiveDocument.FilePath == null ? "Untitled.schx" : Path.GetFileName(ActiveDocument.FilePath), + FileTypeChoices = new[] { new FilePickerFileType("Circuit Schematics") { Patterns = new[] { "*.schx" } } }, + DefaultExtension = "schx" + }); + + string? path = file?.TryGetLocalPath(); + if (path != null) + SaveDocument(ActiveDocument, path); + } + + private async System.Threading.Tasks.Task SaveAllAsync() + { + foreach (TabItem tab in documents.Items.OfType().ToList()) + { + suppressSelectionEvent = true; + documents.SelectedItem = tab; + suppressSelectionEvent = false; + await SaveActiveAsync(); + } + OnActiveDocumentChanged(); + } + + private void SaveDocument(SchematicDocument document, string path) + { + try + { + document.Save(path); + settings.MarkUsed(path); + settings.Save(); + RefreshRecentFilesMenu(); + RefreshDocumentHeaders(); + status.Text = $"Saved {Path.GetFileName(path)}"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void RefreshRecentFilesMenu() + { + List items = settings.ExistingRecentFiles() + .Select(path => MenuItem(CompactPath(path, 48), (_, _) => LoadSchematic(path))) + .Cast() + .ToList(); + + if (items.Count == 0) + items.Add(new MenuItem { Header = "(empty)", IsEnabled = false }); + + recentFilesMenu.ItemsSource = items; + } + + private static string CompactPath(string path, int maxLength) + { + if (path.Length <= maxLength) + return path; + + string file = Path.GetFileName(path); + string directory = Path.GetDirectoryName(path) ?? string.Empty; + int keep = Math.Max(0, maxLength - file.Length - 5); + if (directory.Length > keep) + directory = "..." + directory[^keep..]; + return Path.Combine(directory, file); + } + + private void PopulateComponents() + { + List items = CommonComponents.Select(i => new ComponentListItem(i)).ToList(); + Type root = typeof(CircuitComponent); + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies().Where(i => !i.IsDynamic)) + { + try + { + foreach (Type type in assembly.GetTypes().Where(i => i.IsPublic && !i.IsAbstract && root.IsAssignableFrom(i) && i.CustomAttribute() == null)) + if (items.All(i => i.ComponentType != type)) + items.Add(new ComponentListItem(type)); + } + catch + { + } + } + + componentList.ItemsSource = items.OrderBy(i => i.Name).ToList(); + } + + private void ActivateSelectedComponent() + { + if (componentList.SelectedItem is not ComponentListItem item || ActiveCanvas == null) + return; + + if (item.ComponentType == typeof(Conductor)) + { + ActiveCanvas.BeginWireTool(); + status.Text = "Wire tool: click two grid points"; + return; + } + + ActiveCanvas.PendingComponent = (CircuitComponent)Activator.CreateInstance(item.ComponentType)!; + status.Text = $"Place {item.Name}: click schematic"; + } + + private void BeginWireTool() + { + componentList.SelectedItem = null; + ActiveCanvas?.BeginWireTool(); + status.Text = "Wire tool: click two grid points"; + } + + private void ValidateActiveCircuit() + { + try + { + ActiveDocument?.Schematic.Build(); + status.Text = "Circuit build succeeded"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void SaveScreenshotAndExit(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + RenderTargetBitmap bitmap = new RenderTargetBitmap(new PixelSize((int)Bounds.Width, (int)Bounds.Height), new Vector(96, 96)); + bitmap.Render(this); + bitmap.Save(path); + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.Shutdown(); + } + + private void UndoActive() + { + ActiveDocument?.Undo(); + ActiveCanvas?.InvalidateVisual(); + RefreshDocumentHeaders(); + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + status.Text = "Undo"; + } + + private void RedoActive() + { + ActiveDocument?.Redo(); + ActiveCanvas?.InvalidateVisual(); + RefreshDocumentHeaders(); + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + status.Text = "Redo"; + } + + private void RenderSmoke() + { + try + { + if (ActiveDocument == null) + return; + + new WaveformWindow(ActiveDocument.Schematic).Show(); + status.Text = "Simulation window opened"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private async System.Threading.Tasks.Task CopySelectionAsync() + { + string? xml = ActiveCanvas?.CopySelectionXml(); + if (!string.IsNullOrWhiteSpace(xml) && Clipboard != null) + { + await Clipboard.SetTextAsync(xml); + status.Text = "Copied selection"; + } + } + + private async System.Threading.Tasks.Task CutSelectionAsync() + { + await CopySelectionAsync(); + ActiveCanvas?.DeleteSelection(); + status.Text = "Cut selection"; + } + + private async System.Threading.Tasks.Task PasteSelectionAsync() + { + if (ActiveCanvas == null || Clipboard == null) + return; + + string? xml = await Clipboard.GetTextAsync(); + if (!string.IsNullOrWhiteSpace(xml) && ActiveCanvas.PasteSelectionXml(xml)) + status.Text = "Pasted selection"; + } + + private async void OnKeyDown(object? sender, KeyEventArgs e) + { + KeyModifiers modifiers = e.KeyModifiers; + if (modifiers.HasFlag(KeyModifiers.Control)) + { + switch (e.Key) + { + case Key.N: + NewDocument(); + e.Handled = true; + return; + case Key.O: + await OpenSchematicAsync(); + e.Handled = true; + return; + case Key.S when modifiers.HasFlag(KeyModifiers.Shift): + await SaveAllAsync(); + e.Handled = true; + return; + case Key.S: + await SaveActiveAsync(); + e.Handled = true; + return; + case Key.C: + await CopySelectionAsync(); + e.Handled = true; + return; + case Key.X: + await CutSelectionAsync(); + e.Handled = true; + return; + case Key.Z: + UndoActive(); + e.Handled = true; + return; + case Key.Y: + RedoActive(); + e.Handled = true; + return; + case Key.V: + await PasteSelectionAsync(); + e.Handled = true; + return; + case Key.A: + ActiveCanvas?.SelectAll(); + e.Handled = true; + return; + } + } + + switch (e.Key) + { + case Key.Delete: + ActiveCanvas?.DeleteSelection(); + e.Handled = true; + break; + case Key.F5: + RenderSmoke(); + e.Handled = true; + break; + case Key.Left: + ActiveCanvas?.RotateSelection(1); + e.Handled = true; + break; + case Key.Right: + ActiveCanvas?.RotateSelection(-1); + e.Handled = true; + break; + case Key.Up: + case Key.Down: + ActiveCanvas?.FlipSelection(); + e.Handled = true; + break; + } + } +} + +internal sealed class ComponentListItem +{ + public ComponentListItem(Type componentType) + { + ComponentType = componentType; + CircuitComponent component = (CircuitComponent)Activator.CreateInstance(componentType)!; + Name = component.TypeName; + Description = componentType.CustomAttribute()?.Description ?? componentType.Name; + } + + public Type ComponentType { get; } + + public string Name { get; } + + public string Description { get; } + + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/Program.cs b/LiveSPICE.Avalonia/Program.cs new file mode 100644 index 00000000..ab7ffcda --- /dev/null +++ b/LiveSPICE.Avalonia/Program.cs @@ -0,0 +1,22 @@ +using Avalonia; + +namespace LiveSPICE.Avalonia; + +internal static class Program +{ + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .With(new X11PlatformOptions + { + RenderingMode = new[] { X11RenderingMode.Software } + }) + .LogToTrace(); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/PropertyInspector.cs b/LiveSPICE.Avalonia/PropertyInspector.cs new file mode 100644 index 00000000..c02bc086 --- /dev/null +++ b/LiveSPICE.Avalonia/PropertyInspector.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Layout; +using Circuit; +using Util; + +namespace LiveSPICE.Avalonia; + +public sealed class PropertyInspector : ScrollViewer +{ + private readonly StackPanel panel = new StackPanel { Spacing = 6, Margin = new global::Avalonia.Thickness(8) }; + private IReadOnlyList selectedObjects = Array.Empty(); + + public PropertyInspector() + { + Content = panel; + } + + public event Action? PropertyChangedByUser; + + public void SetSelectedObject(object? value) + { + selectedObjects = value == null ? Array.Empty() : new[] { value }; + Rebuild(); + } + + public void SetSelectedObjects(IReadOnlyList values) + { + selectedObjects = values; + Rebuild(); + } + + private void Rebuild() + { + panel.Children.Clear(); + + if (selectedObjects.Count == 0) + { + panel.Children.Add(new TextBlock { Text = "No selection" }); + return; + } + + object first = selectedObjects[0]; + panel.Children.Add(new TextBlock { Text = selectedObjects.Count == 1 ? first.ToString() : selectedObjects.Count + " objects", FontWeight = global::Avalonia.Media.FontWeight.Bold }); + + if (selectedObjects.Any(i => i.GetType() != first.GetType())) + { + panel.Children.Add(new TextBlock { Text = "Mixed selection" }); + return; + } + + foreach (PropertyInfo property in EditableProperties(first)) + AddEditor(selectedObjects, property); + } + + private static IEnumerable EditableProperties(object instance) + { + return instance.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(i => i.CanRead && i.CanWrite) + .Where(i => i.CustomAttribute()?.Browsable != false) + .Where(i => i.CustomAttribute() != null || IsSimple(i.PropertyType)); + } + + private static bool IsSimple(Type type) + { + return type == typeof(string) || type == typeof(int) || type == typeof(double) || type == typeof(decimal) || type == typeof(bool) || type.IsEnum; + } + + private void AddEditor(IReadOnlyList targets, PropertyInfo property) + { + TextBlock label = new TextBlock + { + Text = property.Name, + VerticalAlignment = VerticalAlignment.Center + }; + + Control editor; + if (property.PropertyType == typeof(bool)) + { + CheckBox checkBox = new CheckBox { IsChecked = SharedValue(targets, property) as bool? }; + checkBox.IsCheckedChanged += (_, _) => SetValue(targets, property, checkBox.IsChecked == true); + editor = checkBox; + } + else + { + TextBox textBox = new TextBox { Text = ConvertToString(property, SharedValue(targets, property)) }; + textBox.LostFocus += (_, _) => SetValueFromString(targets, property, textBox.Text ?? ""); + textBox.KeyDown += (_, e) => + { + if (e.Key == global::Avalonia.Input.Key.Enter) + { + SetValueFromString(targets, property, textBox.Text ?? ""); + e.Handled = true; + } + }; + editor = textBox; + } + + Grid row = new Grid { ColumnDefinitions = new ColumnDefinitions("105,*") }; + row.Children.Add(label); + Grid.SetColumn(editor, 1); + row.Children.Add(editor); + panel.Children.Add(row); + } + + private static string ConvertToString(PropertyInfo property, object? value) + { + TypeConverter converter = TypeDescriptor.GetConverter(property.PropertyType); + return converter.ConvertToString(null, CultureInfo.InvariantCulture, value) ?? string.Empty; + } + + private static object? SharedValue(IReadOnlyList targets, PropertyInfo property) + { + object? first = property.GetValue(targets[0]); + return targets.All(i => Equals(property.GetValue(i), first)) ? first : null; + } + + private void SetValueFromString(IReadOnlyList targets, PropertyInfo property, string text) + { + try + { + TypeConverter converter = TypeDescriptor.GetConverter(property.PropertyType); + SetValue(targets, property, converter.ConvertFromString(null, CultureInfo.InvariantCulture, text)); + } + catch + { + Rebuild(); + } + } + + private void SetValue(IReadOnlyList targets, PropertyInfo property, object? value) + { + List before = targets.Select(i => property.GetValue(i)).ToList(); + if (before.All(i => Equals(i, value))) + return; + + PropertyChangedByUser?.Invoke(new PropertyChangeListAction(targets, property, before, value)); + Rebuild(); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/SavePromptWindow.cs b/LiveSPICE.Avalonia/SavePromptWindow.cs new file mode 100644 index 00000000..79b033d5 --- /dev/null +++ b/LiveSPICE.Avalonia/SavePromptWindow.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace LiveSPICE.Avalonia; + +public enum SaveChoice +{ + Save, + Discard, + Cancel +} + +public sealed class SavePromptWindow : Window +{ + private SaveChoice choice = SaveChoice.Cancel; + + private SavePromptWindow(string documentTitle) + { + Title = "Unsaved Changes"; + Width = 420; + Height = 170; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + + TextBlock message = new TextBlock + { + Text = $"Save changes to {documentTitle}?", + TextWrapping = TextWrapping.Wrap, + Margin = new global::Avalonia.Thickness(16, 16, 16, 10) + }; + + StackPanel buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(16) + }; + buttons.Children.Add(Button("Save", SaveChoice.Save)); + buttons.Children.Add(Button("Discard", SaveChoice.Discard)); + buttons.Children.Add(Button("Cancel", SaveChoice.Cancel)); + + DockPanel dock = new DockPanel(); + DockPanel.SetDock(buttons, Dock.Bottom); + dock.Children.Add(buttons); + dock.Children.Add(message); + Content = dock; + } + + public static async Task ShowAsync(Window owner, string documentTitle) + { + SavePromptWindow window = new SavePromptWindow(documentTitle); + await window.ShowDialog(owner); + return window.choice; + } + + private Button Button(string text, SaveChoice result) + { + Button button = new Button { Content = text, MinWidth = 86 }; + button.Click += (_, _) => + { + choice = result; + Close(); + }; + return button; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/SchematicCanvas.cs b/LiveSPICE.Avalonia/SchematicCanvas.cs new file mode 100644 index 00000000..739a202d --- /dev/null +++ b/LiveSPICE.Avalonia/SchematicCanvas.cs @@ -0,0 +1,637 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Circuit; +using APoint = Avalonia.Point; +using AVector = Avalonia.Vector; + +namespace LiveSPICE.Avalonia; + +public sealed class SchematicCanvas : Control +{ + private const double GridSize = 10; + private const double TerminalSize = 3; + private static readonly Typeface TextTypeface = new Typeface("Inter"); + + private Schematic? schematic; + private APoint pan = new APoint(0, 0); + private APoint? dragStart; + private Coord? selectionStart; + private Coord? selectionCurrent; + private bool additiveSelection; + private Element[] selectionBase = Array.Empty(); + private Coord? moveStart; + private Coord moveDelta; + private Element[] moveElements = Array.Empty(); + private Coord? wireStart; + private bool wireMode; + private readonly HashSet selected = new HashSet(); + + public SchematicDocument? Document + { + get => document; + set + { + document = value; + Schematic = value?.Schematic; + } + } + + private SchematicDocument? document; + + public Component? PendingComponent { get; set; } + + public object? SelectedObject => selected.Count == 1 ? (selected.Single() is Symbol symbol ? symbol.Component : selected.Single()) : null; + + public IReadOnlyList SelectedObjects => selected.Select(i => i is Symbol symbol ? symbol.Component : (object)i).ToArray(); + + public event Action? SelectionChanged; + + public event Action? DocumentChanged; + + public Schematic? Schematic + { + get => schematic; + set + { + schematic = value; + FitToView(); + InvalidateVisual(); + } + } + + public double Zoom + { + get => zoom; + set + { + zoom = Math.Clamp(value, 0.1, 20); + InvalidateVisual(); + } + } + + private double zoom = 1; + + public SchematicCanvas() + { + ClipToBounds = true; + Focusable = true; + PointerPressed += OnPointerPressed; + PointerReleased += OnPointerReleased; + PointerMoved += OnPointerMoved; + PointerWheelChanged += OnPointerWheelChanged; + SizeChanged += (_, _) => FitToView(); + } + + public void FitToView() + { + if (schematic == null || Bounds.Width <= 0 || Bounds.Height <= 0) + return; + + Coord lower = schematic.LowerBound; + Coord upper = schematic.UpperBound; + double width = Math.Max(upper.x - lower.x, 200); + double height = Math.Max(upper.y - lower.y, 200); + double fit = Math.Min((Bounds.Width - 80) / width, (Bounds.Height - 80) / height); + + zoom = Math.Clamp(fit, 0.1, 8); + pan = new APoint( + Bounds.Width / 2 - (lower.x + width / 2) * zoom, + Bounds.Height / 2 - (lower.y + height / 2) * zoom); + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { + context.FillRectangle(Brushes.White, Bounds); + DrawGrid(context); + + if (schematic == null) + { + DrawCenteredText(context, "Open a .schx file to view a schematic"); + return; + } + + foreach (Wire wire in schematic.Wires) + DrawWire(context, wire); + + foreach (Symbol symbol in schematic.Symbols) + DrawSymbol(context, symbol); + + if (selectionStart.HasValue && selectionCurrent.HasValue) + { + Rect rect = RectFromPoints(ToScreen(selectionStart.Value), ToScreen(selectionCurrent.Value)); + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(30, 30, 120, 240)), new Pen(Brushes.DodgerBlue, 1, DashStyle.Dash), rect); + } + } + + private void DrawGrid(DrawingContext context) + { + Pen minor = new Pen(new SolidColorBrush(Color.FromRgb(225, 225, 245)), 1); + Pen major = new Pen(new SolidColorBrush(Color.FromRgb(175, 175, 230)), 1); + double step = GridSize * Zoom; + + if (step < 4) + return; + + double startX = pan.X % step; + double startY = pan.Y % step; + for (double x = startX; x < Bounds.Width; x += step) + context.DrawLine(Math.Abs((x - pan.X) / step % 10) < 0.001 ? major : minor, new APoint(x, 0), new APoint(x, Bounds.Height)); + for (double y = startY; y < Bounds.Height; y += step) + context.DrawLine(Math.Abs((y - pan.Y) / step % 10) < 0.001 ? major : minor, new APoint(0, y), new APoint(Bounds.Width, y)); + } + + private void DrawWire(DrawingContext context, Wire wire) + { + Pen wirePen = new Pen(selected.Contains(wire) ? Brushes.DodgerBlue : Brushes.DarkBlue, Math.Max(1, Zoom)); + context.DrawLine(wirePen, ToScreen(wire.A), ToScreen(wire.B)); + DrawTerminal(context, ToScreen(wire.A), Brushes.DarkBlue); + DrawTerminal(context, ToScreen(wire.B), Brushes.DarkBlue); + } + + private void DrawSymbol(DrawingContext context, Symbol symbol) + { + SymbolLayout layout = symbol.Component.LayoutSymbol(); + Transform transform = new Transform(symbol, layout); + + foreach (SymbolLayout.Shape line in layout.Lines) + context.DrawLine(PenFor(line.Edge), ToScreen(transform.Apply(line.x1)), ToScreen(transform.Apply(line.x2))); + + foreach (SymbolLayout.Shape rectangle in layout.Rectangles) + { + Rect rect = RectFromPoints(ToScreen(transform.Apply(rectangle.x1)), ToScreen(transform.Apply(rectangle.x2))); + context.DrawRectangle(rectangle.Fill ? BrushFor(rectangle.Edge) : null, PenFor(rectangle.Edge), rect); + } + + foreach (SymbolLayout.Shape ellipse in layout.Ellipses) + { + Rect rect = RectFromPoints(ToScreen(transform.Apply(ellipse.x1)), ToScreen(transform.Apply(ellipse.x2))); + context.DrawEllipse(ellipse.Fill ? BrushFor(ellipse.Edge) : null, PenFor(ellipse.Edge), rect); + } + + foreach (SymbolLayout.Curve curve in layout.Curves) + DrawCurve(context, transform, curve); + + foreach (SymbolLayout.Arc arc in layout.Arcs) + DrawArc(context, transform, arc); + + foreach (SymbolLayout.Text text in layout.Texts) + DrawText(context, transform, text); + + foreach (Terminal terminal in layout.Terminals) + DrawTerminal(context, ToScreen(transform.Apply(layout.MapTerminal(terminal))), terminal.ConnectedTo == null ? Brushes.Red : Brushes.DarkBlue); + + if (selected.Contains(symbol)) + context.DrawRectangle(null, new Pen(Brushes.DodgerBlue, 1), RectFromPoints(ToScreen(symbol.LowerBound), ToScreen(symbol.UpperBound))); + } + + private void DrawCurve(DrawingContext context, Transform transform, SymbolLayout.Curve curve) + { + if (curve.x.Length < 2) + return; + + Pen pen = PenFor(curve.Edge); + APoint previous = ToScreen(transform.Apply(curve.x[0])); + for (int i = 1; i < curve.x.Length; i++) + { + APoint next = ToScreen(transform.Apply(curve.x[i])); + context.DrawLine(pen, previous, next); + previous = next; + } + } + + private void DrawArc(DrawingContext context, Transform transform, SymbolLayout.Arc arc) + { + const int segments = 24; + double start = arc.StartAngle; + double end = arc.EndAngle; + double sweep = end - start; + + if (arc.Direction == Direction.Clockwise && sweep < 0) + sweep += 2 * Math.PI; + else if (arc.Direction == Direction.Counterclockwise && sweep > 0) + sweep -= 2 * Math.PI; + + Pen pen = PenFor(arc.Type); + Circuit.Point previous = ArcPoint(arc, transform, start); + for (int i = 1; i <= segments; i++) + { + double angle = start + sweep * i / segments; + Circuit.Point next = ArcPoint(arc, transform, angle); + context.DrawLine(pen, ToScreen(previous), ToScreen(next)); + previous = next; + } + } + + private static Circuit.Point ArcPoint(SymbolLayout.Arc arc, Transform transform, double angle) + { + return transform.Apply(new Circuit.Point( + arc.Center.x + Math.Cos(angle) * arc.Radius, + arc.Center.y + Math.Sin(angle) * arc.Radius)); + } + + private void DrawText(DrawingContext context, Transform transform, SymbolLayout.Text text) + { + double size = text.Size switch + { + Circuit.Size.Small => 8, + Circuit.Size.Large => 14, + _ => 10 + }; + FormattedText formatted = new FormattedText(text.String, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, size * Zoom, Brushes.Black); + APoint point = ToScreen(transform.Apply(text.x)); + context.DrawText(formatted, point); + } + + private void DrawTerminal(DrawingContext context, APoint point, IBrush brush) + { + double half = Math.Max(TerminalSize * Zoom / 2, 2); + context.DrawRectangle(brush, null, new Rect(point.X - half, point.Y - half, half * 2, half * 2)); + } + + private void DrawCenteredText(DrawingContext context, string text) + { + FormattedText formatted = new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, 16, Brushes.DimGray); + context.DrawText(formatted, new APoint((Bounds.Width - formatted.Width) / 2, (Bounds.Height - formatted.Height) / 2)); + } + + private APoint ToScreen(Circuit.Point point) + { + return new APoint(point.x * Zoom + pan.X, point.y * Zoom + pan.Y); + } + + private Coord ToSchematic(APoint point) + { + return Snap(new Coord((int)Math.Round((point.X - pan.X) / Zoom), (int)Math.Round((point.Y - pan.Y) / Zoom))); + } + + private static Coord Snap(Coord coord) + { + return new Coord((int)Math.Round(coord.x / GridSize) * (int)GridSize, (int)Math.Round(coord.y / GridSize) * (int)GridSize); + } + + private static Rect RectFromPoints(APoint a, APoint b) + { + return new Rect(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)); + } + + private Pen PenFor(EdgeType edge) + { + return new Pen(BrushFor(edge), Math.Max(1, Zoom)); + } + + private static IBrush BrushFor(EdgeType edge) + { + return edge switch + { + EdgeType.Wire => Brushes.DarkBlue, + EdgeType.Gray => Brushes.Gray, + EdgeType.Red => Brushes.Red, + EdgeType.Green => Brushes.LimeGreen, + EdgeType.Blue => Brushes.Blue, + EdgeType.Yellow => Brushes.Goldenrod, + EdgeType.Cyan => Brushes.Teal, + EdgeType.Magenta => Brushes.Magenta, + EdgeType.Orange => Brushes.Orange, + _ => Brushes.Black + }; + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + Focus(); + APoint point = e.GetPosition(this); + Coord at = ToSchematic(point); + PointerPointProperties properties = e.GetCurrentPoint(this).Properties; + + if (schematic == null) + return; + + if (PendingComponent != null) + { + Symbol symbol = new Symbol(PendingComponent.Clone()) { Position = at }; + document?.Do(new AddElementsAction(schematic, new[] { symbol })); + SetSelection(symbol); + PendingComponent = null; + MarkChanged(false); + return; + } + + if (wireMode) + { + if (!wireStart.HasValue) + { + wireStart = at; + } + else if (wireStart.Value != at) + { + Wire wire = new Wire(wireStart.Value, at); + document?.Do(new AddElementsAction(schematic, new[] { wire })); + SetSelection(wire); + wireStart = null; + wireMode = false; + MarkChanged(false); + } + return; + } + + if (properties.IsMiddleButtonPressed) + { + dragStart = point; + e.Pointer.Capture(this); + return; + } + + Element? hit = HitTest(at); + if (hit != null) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + if (!selected.Add(hit)) + selected.Remove(hit); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + else if (!selected.Contains(hit)) + { + SetSelection(hit); + } + moveStart = at; + moveDelta = new Coord(0, 0); + moveElements = selected.ToArray(); + } + else + { + selectionStart = at; + selectionCurrent = at; + additiveSelection = e.KeyModifiers.HasFlag(KeyModifiers.Control); + selectionBase = additiveSelection ? selected.ToArray() : Array.Empty(); + if (!additiveSelection) + selected.Clear(); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + e.Pointer.Capture(this); + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + dragStart = null; + if (selectionStart.HasValue && selectionCurrent.HasValue) + { + SelectRect(selectionStart.Value, selectionCurrent.Value); + selectionStart = null; + selectionCurrent = null; + additiveSelection = false; + selectionBase = Array.Empty(); + } + if (moveDelta != new Coord(0, 0) && moveElements.Length > 0) + { + document?.Record(new MoveElementsAction(moveElements, moveDelta)); + MarkChanged(false); + } + moveStart = null; + moveDelta = new Coord(0, 0); + moveElements = Array.Empty(); + e.Pointer.Capture(null); + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + APoint current = e.GetPosition(this); + if (selectionStart.HasValue) + { + selectionCurrent = ToSchematic(current); + HighlightRect(selectionStart.Value, selectionCurrent.Value); + InvalidateVisual(); + return; + } + + if (moveStart.HasValue && selected.Count > 0) + { + Coord at = ToSchematic(current); + Coord delta = at - moveStart.Value; + if (delta != new Coord(0, 0)) + { + foreach (Element element in moveElements) + element.Move(delta); + moveDelta += delta; + moveStart = at; + InvalidateVisual(); + } + return; + } + + if (dragStart != null) + { + AVector delta = current - dragStart.Value; + pan += delta; + dragStart = current; + InvalidateVisual(); + } + } + + private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + APoint focus = e.GetPosition(this); + double oldZoom = Zoom; + Zoom *= e.Delta.Y > 0 ? 1.1 : 1 / 1.1; + double ratio = Zoom / oldZoom; + pan = new APoint(focus.X - (focus.X - pan.X) * ratio, focus.Y - (focus.Y - pan.Y) * ratio); + } + + public void BeginWireTool() + { + PendingComponent = null; + wireMode = true; + wireStart = null; + } + + public void DeleteSelection() + { + if (schematic == null || selected.Count == 0) + return; + + Element[] remove = selected.ToArray(); + document?.Do(new RemoveElementsAction(schematic, remove)); + ClearSelection(); + MarkChanged(false); + } + + public string? CopySelectionXml() + { + if (selected.Count == 0) + return null; + + XElement root = new XElement("Schematic"); + foreach (Element element in selected) + root.Add(element.Serialize()); + return root.ToString(); + } + + public bool PasteSelectionXml(string xml) + { + if (schematic == null) + return false; + + try + { + List elements = XElement.Parse(xml).Elements("Element").Select(Element.Deserialize).ToList(); + if (elements.Count == 0) + return false; + + Coord offset = new Coord(20, 20); + foreach (Element element in elements) + element.Move(offset); + + document?.Do(new AddElementsAction(schematic, elements)); + selected.Clear(); + foreach (Element element in elements) + selected.Add(element); + MarkChanged(false); + return true; + } + catch + { + return false; + } + } + + public void SelectAll() + { + if (schematic == null) + return; + + selected.Clear(); + foreach (Element element in schematic.Elements) + selected.Add(element); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + public void RotateSelection(int delta) + { + if (selected.Count == 0) + return; + + Circuit.Point center = SelectionCenter(); + document?.Do(new RotateElementsAction(selected, delta, center)); + MarkChanged(false); + } + + public void FlipSelection() + { + if (selected.Count == 0) + return; + + double y = SelectionCenter().y; + document?.Do(new FlipElementsAction(selected, y)); + MarkChanged(false); + } + + private Circuit.Point SelectionCenter() + { + Coord lower = new Coord(selected.Min(i => i.LowerBound.x), selected.Min(i => i.LowerBound.y)); + Coord upper = new Coord(selected.Max(i => i.UpperBound.x), selected.Max(i => i.UpperBound.y)); + return (lower + upper) / 2; + } + + private Element? HitTest(Coord at) + { + if (schematic == null) + return null; + + return schematic.Elements.Reverse().FirstOrDefault(i => i.Intersects(at - 4, at + 4)); + } + + private void HighlightRect(Coord a, Coord b) + { + if (schematic == null) + return; + + selected.Clear(); + foreach (Element element in selectionBase) + selected.Add(element); + foreach (Element element in ElementsInRect(a, b)) + selected.Add(element); + SelectionChanged?.Invoke(); + } + + private void SelectRect(Coord a, Coord b) + { + HighlightRect(a, b); + InvalidateVisual(); + } + + private IEnumerable ElementsInRect(Coord a, Coord b) + { + if (schematic == null) + return Array.Empty(); + + Coord lower = new Coord(Math.Min(a.x, b.x), Math.Min(a.y, b.y)); + Coord upper = new Coord(Math.Max(a.x, b.x), Math.Max(a.y, b.y)); + return schematic.Elements.Where(i => i.Intersects(lower, upper)).ToArray(); + } + + private void SetSelection(Element element) + { + selected.Clear(); + selected.Add(element); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + private void ClearSelection() + { + selected.Clear(); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + private void MarkChanged(bool markDirty = true) + { + if (markDirty) + document?.MarkDirty(); + DocumentChanged?.Invoke(); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + private readonly struct Transform + { + private readonly Symbol symbol; + private readonly SymbolLayout layout; + + public Transform(Symbol symbol, SymbolLayout layout) + { + this.symbol = symbol; + this.layout = layout; + } + + public Circuit.Point Apply(Circuit.Point local) + { + Circuit.Coord offset = (layout.LowerBound + layout.UpperBound) / 2; + double x = local.x - offset.x; + double y = local.y - offset.y; + if (!symbol.Flip) + y = -y; + + int rotation = ((symbol.Rotation % 4) + 4) % 4; + (x, y) = rotation switch + { + 1 => (y, -x), + 2 => (-x, -y), + 3 => (-y, x), + _ => (x, y) + }; + + return new Circuit.Point(x + symbol.Position.x, y + symbol.Position.y); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/SchematicDocument.cs b/LiveSPICE.Avalonia/SchematicDocument.cs new file mode 100644 index 00000000..50e2dd9a --- /dev/null +++ b/LiveSPICE.Avalonia/SchematicDocument.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Circuit; + +namespace LiveSPICE.Avalonia; + +public sealed class SchematicDocument +{ + public SchematicDocument(Schematic schematic, string? filePath = null) + { + Schematic = schematic; + FilePath = filePath; + UpdateSavedWriteTime(); + } + + public Schematic Schematic { get; } + + public string? FilePath { get; private set; } + + public bool Dirty { get; private set; } + + private DateTime savedWriteTimeUtc; + + private readonly Stack undo = new Stack(); + private readonly Stack redo = new Stack(); + + public string Title + { + get + { + string title = FilePath == null ? "" : Path.GetFileNameWithoutExtension(FilePath); + return Dirty ? title + " *" : title; + } + } + + public static SchematicDocument New() + { + return new SchematicDocument(new Schematic()); + } + + public static SchematicDocument Open(string path) + { + return new SchematicDocument(Schematic.Load(path), path); + } + + public void MarkDirty() + { + Dirty = true; + } + + public bool CanUndo => undo.Count > 0; + + public bool CanRedo => redo.Count > 0; + + public void Do(IEditAction action) + { + action.Do(); + Record(action); + } + + public void Record(IEditAction action) + { + undo.Push(action); + redo.Clear(); + MarkDirty(); + } + + public void Undo() + { + if (undo.Count == 0) + return; + + IEditAction action = undo.Pop(); + action.Undo(); + redo.Push(action); + MarkDirty(); + } + + public void Redo() + { + if (redo.Count == 0) + return; + + IEditAction action = redo.Pop(); + action.Do(); + undo.Push(action); + MarkDirty(); + } + + public void Save(string path) + { + Schematic.Save(path); + FilePath = path; + Dirty = false; + UpdateSavedWriteTime(); + } + + public bool WasModifiedExternally() + { + if (FilePath == null || Dirty || !File.Exists(FilePath)) + return false; + + return File.GetLastWriteTimeUtc(FilePath) != savedWriteTimeUtc; + } + + private void UpdateSavedWriteTime() + { + savedWriteTimeUtc = FilePath != null && File.Exists(FilePath) ? File.GetLastWriteTimeUtc(FilePath) : DateTime.MinValue; + } +} + +public interface IEditAction +{ + void Do(); + + void Undo(); +} + +public sealed class AddElementsAction : IEditAction +{ + private readonly Schematic schematic; + private readonly List elements; + + public AddElementsAction(Schematic schematic, IEnumerable elements) + { + this.schematic = schematic; + this.elements = elements.ToList(); + } + + public IReadOnlyList Elements => elements; + + public void Do() => schematic.Add(elements); + + public void Undo() => schematic.Remove(elements); +} + +public sealed class RemoveElementsAction : IEditAction +{ + private readonly Schematic schematic; + private readonly List elements; + + public RemoveElementsAction(Schematic schematic, IEnumerable elements) + { + this.schematic = schematic; + this.elements = elements.ToList(); + } + + public void Do() => schematic.Remove(elements); + + public void Undo() => schematic.Add(elements); +} + +public sealed class MoveElementsAction : IEditAction +{ + private readonly List elements; + private readonly Coord delta; + + public MoveElementsAction(IEnumerable elements, Coord delta) + { + this.elements = elements.ToList(); + this.delta = delta; + } + + public void Do() + { + foreach (Element element in elements) + element.Move(delta); + } + + public void Undo() + { + foreach (Element element in elements) + element.Move(-delta); + } +} + +public sealed class RotateElementsAction : IEditAction +{ + private readonly List elements; + private readonly int delta; + private readonly Point center; + + public RotateElementsAction(IEnumerable elements, int delta, Point center) + { + this.elements = elements.ToList(); + this.delta = delta; + this.center = center; + } + + public void Do() + { + foreach (Element element in elements) + element.RotateAround(delta, center); + } + + public void Undo() + { + foreach (Element element in elements) + element.RotateAround(-delta, center); + } +} + +public sealed class FlipElementsAction : IEditAction +{ + private readonly List elements; + private readonly double y; + + public FlipElementsAction(IEnumerable elements, double y) + { + this.elements = elements.ToList(); + this.y = y; + } + + public void Do() + { + foreach (Element element in elements) + element.FlipOver(y); + } + + public void Undo() => Do(); +} + +public sealed class PropertyChangeAction : IEditAction +{ + private readonly object target; + private readonly PropertyInfo property; + private readonly object? before; + private readonly object? after; + + public PropertyChangeAction(object target, PropertyInfo property, object? before, object? after) + { + this.target = target; + this.property = property; + this.before = before; + this.after = after; + } + + public void Do() => property.SetValue(target, after); + + public void Undo() => property.SetValue(target, before); +} + +public sealed class PropertyChangeListAction : IEditAction +{ + private readonly List targets; + private readonly PropertyInfo property; + private readonly List before; + private readonly object? after; + + public PropertyChangeListAction(IEnumerable targets, PropertyInfo property, IEnumerable before, object? after) + { + this.targets = targets.ToList(); + this.property = property; + this.before = before.ToList(); + this.after = after; + } + + public void Do() + { + foreach (object target in targets) + property.SetValue(target, after); + } + + public void Undo() + { + for (int i = 0; i < targets.Count; i++) + property.SetValue(targets[i], before[i]); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs new file mode 100644 index 00000000..f9d81b66 --- /dev/null +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -0,0 +1,207 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Circuit; +using APoint = Avalonia.Point; + +namespace LiveSPICE.Avalonia; + +public sealed class WaveformWindow : Window +{ + private readonly Schematic schematic; + private readonly WaveformView waveform = new WaveformView(); + private readonly TextBox oversample = new TextBox { Text = "8", Width = 52 }; + private readonly TextBox iterations = new TextBox { Text = "8", Width = 52 }; + private readonly TextBox samples = new TextBox { Text = "4096", Width = 72 }; + private readonly TextBox frequency = new TextBox { Text = "440", Width = 72 }; + private readonly Slider inputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; + private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; + private readonly TextBox log = new TextBox { IsReadOnly = true, AcceptsReturn = true, MinHeight = 100, TextWrapping = TextWrapping.Wrap }; + + public WaveformWindow(Schematic schematic) + { + this.schematic = schematic; + Title = "Simulation Scope"; + Width = 1000; + Height = 700; + MinWidth = 520; + MinHeight = 420; + Content = BuildContent(); + RunSimulation(); + } + + private Control BuildContent() + { + DockPanel root = new DockPanel(); + + StackPanel toolbar = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Margin = new global::Avalonia.Thickness(8), + VerticalAlignment = VerticalAlignment.Center + }; + toolbar.Children.Add(Label("Oversample")); + toolbar.Children.Add(oversample); + toolbar.Children.Add(Label("Iterations")); + toolbar.Children.Add(iterations); + toolbar.Children.Add(Label("Samples")); + toolbar.Children.Add(samples); + toolbar.Children.Add(Label("Hz")); + toolbar.Children.Add(frequency); + toolbar.Children.Add(Button("Run", (_, _) => RunSimulation())); + DockPanel.SetDock(toolbar, Dock.Top); + root.Children.Add(toolbar); + + Grid main = new Grid { ColumnDefinitions = new ColumnDefinitions("220,*"), RowDefinitions = new RowDefinitions("*,150") }; + + StackPanel audio = new StackPanel { Spacing = 10, Margin = new global::Avalonia.Thickness(10) }; + audio.Children.Add(Header("Audio")); + audio.Children.Add(Label("Input gain (dB)")); + audio.Children.Add(inputGain); + audio.Children.Add(Label("Output gain (dB)")); + audio.Children.Add(outputGain); + audio.Children.Add(Header("Input")); + audio.Children.Add(new TextBlock { Text = "Generated sine", TextWrapping = TextWrapping.Wrap }); + audio.Children.Add(Header("Output")); + audio.Children.Add(new TextBlock { Text = "First output channel", TextWrapping = TextWrapping.Wrap }); + Grid.SetColumn(audio, 0); + Grid.SetRowSpan(audio, 2); + main.Children.Add(audio); + + Border plotBorder = new Border { BorderBrush = Brushes.LightGray, BorderThickness = new global::Avalonia.Thickness(1), Child = waveform }; + Grid.SetColumn(plotBorder, 1); + Grid.SetRow(plotBorder, 0); + main.Children.Add(plotBorder); + + log.Margin = new global::Avalonia.Thickness(8); + Grid.SetColumn(log, 1); + Grid.SetRow(log, 1); + main.Children.Add(log); + + root.Children.Add(main); + return root; + } + + private void RunSimulation() + { + try + { + int oversampleValue = ParseInt(oversample.Text, 8, 1, 64); + int iterationValue = ParseInt(iterations.Text, 8, 1, 64); + int sampleCount = ParseInt(samples.Text, 4096, 64, 262144); + double frequencyValue = ParseDouble(frequency.Text, 440, 0.1, 96000); + int sampleRate = 48000; + + Circuit.Circuit circuit = schematic.Build(); + Simulation simulation = AudioSimulationFactory.Create(circuit, sampleRate, 1, oversampleValue); + simulation.Iterations = iterationValue; + + double inGain = DbToLinear(inputGain.Value); + double outGain = DbToLinear(outputGain.Value); + double[] input = new double[sampleCount]; + double[] output = new double[sampleCount]; + for (int i = 0; i < input.Length; i++) + input[i] = inGain * 0.25 * Math.Sin(2 * Math.PI * frequencyValue * i / sampleRate); + + simulation.Run(input.Length, new[] { input }, new[] { output }); + for (int i = 0; i < output.Length; i++) + output[i] *= outGain; + + waveform.SetSamples(output, sampleRate); + log.Text = $"Build succeeded\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {output.Select(Math.Abs).DefaultIfEmpty().Max():R}"; + } + catch (Exception ex) + { + log.Text = ex.ToString(); + } + } + + private static TextBlock Header(string text) + { + return new TextBlock { Text = text, FontWeight = FontWeight.Bold, Margin = new global::Avalonia.Thickness(0, 8, 0, 0) }; + } + + private static TextBlock Label(string text) + { + return new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center }; + } + + private static Button Button(string text, EventHandler click) + { + Button button = new Button { Content = text, MinWidth = 70 }; + button.Click += click; + return button; + } + + private static int ParseInt(string? text, int fallback, int min, int max) + { + return int.TryParse(text, out int value) ? Math.Clamp(value, min, max) : fallback; + } + + private static double ParseDouble(string? text, double fallback, double min, double max) + { + return double.TryParse(text, out double value) ? Math.Clamp(value, min, max) : fallback; + } + + private static double DbToLinear(double db) + { + return Math.Pow(10, db / 20); + } +} + +public sealed class WaveformView : Control +{ + private static readonly Typeface TextTypeface = new Typeface("Inter"); + private double[] samples = Array.Empty(); + private int sampleRate = 48000; + + public void SetSamples(double[] value, int rate) + { + samples = value; + sampleRate = rate; + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { + context.FillRectangle(Brushes.White, Bounds); + Rect plot = new Rect(48, 28, Math.Max(1, Bounds.Width - 72), Math.Max(1, Bounds.Height - 72)); + context.DrawRectangle(null, new Pen(Brushes.LightGray, 1), plot); + context.DrawLine(new Pen(Brushes.Gray, 1), new APoint(plot.Left, plot.Center.Y), new APoint(plot.Right, plot.Center.Y)); + + if (samples.Length == 0) + return; + + double peak = Math.Max(samples.Select(Math.Abs).DefaultIfEmpty().Max(), 1e-9); + Pen waveform = new Pen(Brushes.DodgerBlue, 1.4); + APoint previous = SamplePoint(0, peak, plot); + for (int i = 1; i < samples.Length; i++) + { + APoint next = SamplePoint(i, peak, plot); + context.DrawLine(waveform, previous, next); + previous = next; + } + + DrawText(context, $"{samples.Length / (double)sampleRate:0.000}s peak {peak:0.000000}", new APoint(48, 6)); + DrawText(context, "+peak", new APoint(6, plot.Top)); + DrawText(context, "0", new APoint(24, plot.Center.Y - 8)); + DrawText(context, "-peak", new APoint(6, plot.Bottom - 16)); + } + + private APoint SamplePoint(int index, double peak, Rect plot) + { + double x = plot.Left + (samples.Length == 1 ? 0 : index * plot.Width / (samples.Length - 1)); + double y = plot.Center.Y - samples[index] / peak * plot.Height / 2; + return new APoint(x, y); + } + + private static void DrawText(DrawingContext context, string text, APoint point) + { + FormattedText formatted = new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, 12, Brushes.DimGray); + context.DrawText(formatted, point); + } +} \ No newline at end of file diff --git a/LiveSPICE.sln b/LiveSPICE.sln index da7a9739..88251977 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -38,6 +38,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{FF178A26-25B0-462A-9927-4FD42C710136}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia", "LiveSPICE.Avalonia\LiveSPICE.Avalonia.csproj", "{4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -100,6 +102,10 @@ Global {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.Build.0 = Release|Any CPU + {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 096948167aeb77f2984e0d248a9e7efa3b4ed6ed Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 19:40:32 +0200 Subject: [PATCH 07/42] Add virtual audio configuration flow --- LiveSPICE.Avalonia/AudioConfigWindow.cs | 78 ++++++++++++++++++++- LiveSPICE.Avalonia/MainWindow.cs | 10 +-- LiveSPICE.Avalonia/VirtualAudioDriver.cs | 86 ++++++++++++++++++++++++ LiveSPICE.Avalonia/WaveformWindow.cs | 34 ++++++++-- 4 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 LiveSPICE.Avalonia/VirtualAudioDriver.cs diff --git a/LiveSPICE.Avalonia/AudioConfigWindow.cs b/LiveSPICE.Avalonia/AudioConfigWindow.cs index 5c32d3b2..7a582551 100644 --- a/LiveSPICE.Avalonia/AudioConfigWindow.cs +++ b/LiveSPICE.Avalonia/AudioConfigWindow.cs @@ -15,6 +15,7 @@ public sealed class AudioConfigWindow : Window private readonly ListBox inputs = new ListBox { SelectionMode = SelectionMode.Multiple }; private readonly ListBox outputs = new ListBox { SelectionMode = SelectionMode.Multiple }; private readonly TextBlock status = new TextBlock(); + private Audio.Stream? testStream; public AudioConfigWindow(AppSettings settings) { @@ -24,8 +25,11 @@ public AudioConfigWindow(AppSettings settings) Height = 500; MinWidth = 520; MinHeight = 360; + drivers.SelectionChanged += (_, _) => PopulateDevices(); + devices.SelectionChanged += (_, _) => PopulateChannels(); Content = BuildContent(); PopulateDrivers(); + Closed += (_, _) => StopTest(); } private Control BuildContent() @@ -40,6 +44,7 @@ private Control BuildContent() Margin = new global::Avalonia.Thickness(10) }; buttons.Children.Add(Button("Refresh", (_, _) => PopulateDrivers())); + buttons.Children.Add(Button("Test", (_, _) => ToggleTest())); buttons.Children.Add(Button("Save", (_, _) => SaveAndClose())); DockPanel.SetDock(buttons, Dock.Bottom); root.Children.Add(buttons); @@ -67,7 +72,6 @@ private void PopulateDrivers() List availableDrivers = Audio.Driver.Drivers.ToList(); drivers.ItemsSource = availableDrivers; drivers.SelectedItem = availableDrivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? availableDrivers.FirstOrDefault(); - drivers.SelectionChanged += (_, _) => PopulateDevices(); PopulateDevices(); status.Text = availableDrivers.Count == 0 ? "No audio drivers are available in this build." : "Ready"; } @@ -78,7 +82,6 @@ private void PopulateDevices() List availableDevices = driver?.Devices.ToList() ?? new List(); devices.ItemsSource = availableDevices; devices.SelectedItem = availableDevices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? availableDevices.FirstOrDefault(); - devices.SelectionChanged += (_, _) => PopulateChannels(); PopulateChannels(); } @@ -93,6 +96,7 @@ private void PopulateChannels() private void SaveAndClose() { + StopTest(); settings.AudioDriver = (drivers.SelectedItem as Audio.Driver)?.Name ?? string.Empty; settings.AudioDevice = (devices.SelectedItem as Audio.Device)?.Name ?? string.Empty; settings.AudioInputs = inputs.SelectedItems?.OfType().Select(i => i.Name).ToList() ?? new List(); @@ -101,6 +105,76 @@ private void SaveAndClose() Close(); } + private void ToggleTest() + { + if (testStream != null) + { + StopTest(); + return; + } + + Audio.Device? device = devices.SelectedItem as Audio.Device; + if (device == null) + { + status.Text = "No audio device selected."; + return; + } + + try + { + testStream = device.Open(TestCallback, SelectedChannels(inputs), SelectedChannels(outputs)); + drivers.IsEnabled = false; + devices.IsEnabled = false; + inputs.IsEnabled = false; + outputs.IsEnabled = false; + status.Text = $"Testing at {testStream.SampleRate:0} Hz"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void StopTest() + { + if (testStream == null) + return; + + try + { + testStream.Stop(); + } + finally + { + testStream = null; + drivers.IsEnabled = true; + devices.IsEnabled = true; + inputs.IsEnabled = true; + outputs.IsEnabled = true; + status.Text = "Ready"; + } + } + + private static void TestCallback(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + for (int sample = 0; sample < count; sample++) + { + double value = 0; + foreach (Audio.SampleBuffer buffer in input) + value += buffer[sample]; + if (input.Length == 0) + value = 0.15 * Math.Sin(2 * Math.PI * 440 * sample / rate); + + foreach (Audio.SampleBuffer buffer in output) + buffer[sample] = value; + } + } + + private static Audio.Channel[] SelectedChannels(ListBox list) + { + return list.SelectedItems?.OfType().ToArray() ?? Array.Empty(); + } + private static void SelectSaved(ListBox list, IEnumerable names) { if (list.SelectedItems == null) diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs index 6b1904cc..7dae60b8 100644 --- a/LiveSPICE.Avalonia/MainWindow.cs +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -172,7 +172,7 @@ private Menu BuildMenu() { MenuItem("Audio Settings", (_, _) => new AudioConfigWindow(settings).Show()), MenuItem("Validate Build", (_, _) => ValidateActiveCircuit()), - MenuItem("Render Smoke", (_, _) => RenderSmoke()) + MenuItem("Run Simulation", (_, _) => RunSimulation()) } }, new MenuItem @@ -214,7 +214,7 @@ private Control BuildToolbar() Button("Paste", async (_, _) => await PasteSelectionAsync()), Button("Delete", (_, _) => ActiveCanvas?.DeleteSelection()), Button("Wire", (_, _) => BeginWireTool()), - Button("Run", (_, _) => RenderSmoke()), + Button("Run", (_, _) => RunSimulation()), Button("+", (_, _) => ZoomActive(1.2), 36), Button("-", (_, _) => ZoomActive(1 / 1.2), 36), Button("Fit", (_, _) => ActiveCanvas?.FitToView()) @@ -657,14 +657,14 @@ private void RedoActive() status.Text = "Redo"; } - private void RenderSmoke() + private void RunSimulation() { try { if (ActiveDocument == null) return; - new WaveformWindow(ActiveDocument.Schematic).Show(); + new WaveformWindow(ActiveDocument.Schematic, settings).Show(); status.Text = "Simulation window opened"; } catch (Exception ex) @@ -757,7 +757,7 @@ private async void OnKeyDown(object? sender, KeyEventArgs e) e.Handled = true; break; case Key.F5: - RenderSmoke(); + RunSimulation(); e.Handled = true; break; case Key.Left: diff --git a/LiveSPICE.Avalonia/VirtualAudioDriver.cs b/LiveSPICE.Avalonia/VirtualAudioDriver.cs new file mode 100644 index 00000000..8acd5911 --- /dev/null +++ b/LiveSPICE.Avalonia/VirtualAudioDriver.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; + +namespace LiveSPICE.Avalonia; + +public sealed class VirtualAudioDriver : Audio.Driver +{ + public VirtualAudioDriver() + { + devices.Add(new VirtualAudioDevice()); + } + + public override string Name => "Virtual Audio"; +} + +internal sealed class VirtualAudioChannel : Audio.Channel +{ + private readonly string name; + + public VirtualAudioChannel(string name) + { + this.name = name; + } + + public override string Name => name; + + public override string ToString() + { + return name; + } +} + +internal sealed class VirtualAudioDevice : Audio.Device +{ + public VirtualAudioDevice() : base("Managed loopback") + { + inputs = new Audio.Channel[] { new VirtualAudioChannel("Input 1") }; + outputs = new Audio.Channel[] { new VirtualAudioChannel("Output 1") }; + } + + public override Audio.Stream Open(Audio.Stream.SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) + { + return new VirtualAudioStream(callback, input, output); + } +} + +internal sealed class VirtualAudioStream : Audio.Stream +{ + private const int Rate = 48000; + private const int BufferSize = 256; + private readonly SampleHandler callback; + private readonly Thread thread; + private volatile bool stopped; + + public VirtualAudioStream(SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) : base(input, output) + { + this.callback = callback; + thread = new Thread(Run) { Name = "Virtual Audio Stream", IsBackground = true }; + thread.Start(); + } + + public override double SampleRate => Rate; + + public override void Stop() + { + stopped = true; + thread.Join(); + } + + private void Run() + { + using Audio.SampleBuffer input = new Audio.SampleBuffer(BufferSize); + using Audio.SampleBuffer output = new Audio.SampleBuffer(BufferSize); + Audio.SampleBuffer[] inputBuffers = InputChannels.Length == 0 ? Array.Empty() : new[] { input }; + Audio.SampleBuffer[] outputBuffers = OutputChannels.Length == 0 ? Array.Empty() : new[] { output }; + TimeSpan interval = TimeSpan.FromSeconds(BufferSize / (double)Rate); + + while (!stopped) + { + input.Clear(); + output.Clear(); + callback(BufferSize, inputBuffers, outputBuffers, Rate); + Thread.Sleep(interval); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index f9d81b66..e9d8daf7 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -12,6 +12,7 @@ namespace LiveSPICE.Avalonia; public sealed class WaveformWindow : Window { private readonly Schematic schematic; + private readonly AppSettings settings; private readonly WaveformView waveform = new WaveformView(); private readonly TextBox oversample = new TextBox { Text = "8", Width = 52 }; private readonly TextBox iterations = new TextBox { Text = "8", Width = 52 }; @@ -19,17 +20,20 @@ public sealed class WaveformWindow : Window private readonly TextBox frequency = new TextBox { Text = "440", Width = 72 }; private readonly Slider inputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; + private readonly TextBlock audioConfig = new TextBlock { TextWrapping = TextWrapping.Wrap }; private readonly TextBox log = new TextBox { IsReadOnly = true, AcceptsReturn = true, MinHeight = 100, TextWrapping = TextWrapping.Wrap }; - public WaveformWindow(Schematic schematic) + public WaveformWindow(Schematic schematic, AppSettings settings) { this.schematic = schematic; + this.settings = settings; Title = "Simulation Scope"; Width = 1000; Height = 700; MinWidth = 520; MinHeight = 420; Content = BuildContent(); + UpdateAudioSummary(); RunSimulation(); } @@ -60,14 +64,21 @@ private Control BuildContent() StackPanel audio = new StackPanel { Spacing = 10, Margin = new global::Avalonia.Thickness(10) }; audio.Children.Add(Header("Audio")); + audio.Children.Add(audioConfig); + audio.Children.Add(Button("Configure", (_, _) => + { + AudioConfigWindow window = new AudioConfigWindow(settings); + window.Closed += (_, _) => UpdateAudioSummary(); + window.Show(); + })); audio.Children.Add(Label("Input gain (dB)")); audio.Children.Add(inputGain); audio.Children.Add(Label("Output gain (dB)")); audio.Children.Add(outputGain); audio.Children.Add(Header("Input")); - audio.Children.Add(new TextBlock { Text = "Generated sine", TextWrapping = TextWrapping.Wrap }); + audio.Children.Add(new TextBlock { Text = "Generated sine" }); audio.Children.Add(Header("Output")); - audio.Children.Add(new TextBlock { Text = "First output channel", TextWrapping = TextWrapping.Wrap }); + audio.Children.Add(new TextBlock { Text = "First output channel" }); Grid.SetColumn(audio, 0); Grid.SetRowSpan(audio, 2); main.Children.Add(audio); @@ -112,7 +123,7 @@ private void RunSimulation() output[i] *= outGain; waveform.SetSamples(output, sampleRate); - log.Text = $"Build succeeded\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {output.Select(Math.Abs).DefaultIfEmpty().Max():R}"; + log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {output.Select(Math.Abs).DefaultIfEmpty().Max():R}"; } catch (Exception ex) { @@ -125,6 +136,21 @@ private static TextBlock Header(string text) return new TextBlock { Text = text, FontWeight = FontWeight.Bold, Margin = new global::Avalonia.Thickness(0, 8, 0, 0) }; } + private void UpdateAudioSummary() + { + audioConfig.Text = $"{AudioName(settings.AudioDriver)} / {AudioName(settings.AudioDevice)}\nIn: {ChannelNames(settings.AudioInputs)}\nOut: {ChannelNames(settings.AudioOutputs)}"; + } + + private static string AudioName(string value) + { + return string.IsNullOrWhiteSpace(value) ? "None" : value; + } + + private static string ChannelNames(System.Collections.Generic.IReadOnlyCollection channels) + { + return channels.Count == 0 ? "None" : string.Join(", ", channels); + } + private static TextBlock Label(string text) { return new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center }; From e035476c5c0091ff8946975d244c0044918cf3c6 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 19:41:31 +0200 Subject: [PATCH 08/42] Add live audio simulation mode --- LiveSPICE.Avalonia/WaveformWindow.cs | 113 +++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index e9d8daf7..f2194c01 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; using Circuit; using APoint = Avalonia.Point; @@ -22,6 +23,9 @@ public sealed class WaveformWindow : Window private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; private readonly TextBlock audioConfig = new TextBlock { TextWrapping = TextWrapping.Wrap }; private readonly TextBox log = new TextBox { IsReadOnly = true, AcceptsReturn = true, MinHeight = 100, TextWrapping = TextWrapping.Wrap }; + private readonly object simulationLock = new object(); + private Audio.Stream? liveStream; + private Simulation? liveSimulation; public WaveformWindow(Schematic schematic, AppSettings settings) { @@ -35,6 +39,7 @@ public WaveformWindow(Schematic schematic, AppSettings settings) Content = BuildContent(); UpdateAudioSummary(); RunSimulation(); + Closed += (_, _) => StopLive(); } private Control BuildContent() @@ -57,6 +62,7 @@ private Control BuildContent() toolbar.Children.Add(Label("Hz")); toolbar.Children.Add(frequency); toolbar.Children.Add(Button("Run", (_, _) => RunSimulation())); + toolbar.Children.Add(Button("Live", (_, _) => ToggleLive())); DockPanel.SetDock(toolbar, Dock.Top); root.Children.Add(toolbar); @@ -131,6 +137,113 @@ private void RunSimulation() } } + private void ToggleLive() + { + if (liveStream != null) + { + StopLive(); + return; + } + + try + { + Audio.Device device = SelectedDevice(); + Audio.Channel[] input = SelectedChannels(device.InputChannels, settings.AudioInputs).ToArray(); + Audio.Channel[] output = SelectedChannels(device.OutputChannels, settings.AudioOutputs).ToArray(); + if (input.Length == 0 && device.InputChannels.Length > 0) + input = new[] { device.InputChannels[0] }; + if (output.Length == 0 && device.OutputChannels.Length > 0) + output = new[] { device.OutputChannels[0] }; + + int oversampleValue = ParseInt(oversample.Text, 8, 1, 64); + int iterationValue = ParseInt(iterations.Text, 8, 1, 64); + lock (simulationLock) + { + liveSimulation = AudioSimulationFactory.Create(schematic.Build(), 48000, oversampleValue, iterationValue); + } + + liveStream = device.Open(ProcessLiveSamples, input, output); + lock (simulationLock) + { + liveSimulation = AudioSimulationFactory.Create(schematic.Build(), liveStream.SampleRate, oversampleValue, iterationValue); + } + log.Text = $"Live stream started\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {device.Name}\nSample rate: {liveStream.SampleRate:0}"; + } + catch (Exception ex) + { + StopLive(); + log.Text = ex.ToString(); + } + } + + private void StopLive() + { + Audio.Stream? stream = liveStream; + liveStream = null; + if (stream != null) + stream.Stop(); + lock (simulationLock) + { + liveSimulation = null; + } + } + + private void ProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + try + { + Simulation? simulation; + lock (simulationLock) + simulation = liveSimulation; + + if (simulation == null) + { + foreach (Audio.SampleBuffer buffer in output) + buffer.Clear(); + return; + } + + double inputScale = DbToLinear(inputGain.Value); + double outputScale = DbToLinear(outputGain.Value); + double[] inputSamples = new double[count]; + if (input.Length > 0) + Array.Copy(input[0].Samples, inputSamples, count); + else + for (int sample = 0; sample < count; sample++) + inputSamples[sample] = 0.25 * Math.Sin(2 * Math.PI * 440 * (simulation.Time + sample / rate)); + for (int sample = 0; sample < count; sample++) + inputSamples[sample] *= inputScale; + + double[] outputSamples = new double[count]; + lock (simulationLock) + simulation.Run(count, new[] { inputSamples }, new[] { outputSamples }); + for (int sample = 0; sample < count; sample++) + outputSamples[sample] *= outputScale; + foreach (Audio.SampleBuffer buffer in output) + Array.Copy(outputSamples, buffer.Samples, count); + + Dispatcher.UIThread.Post(() => waveform.SetSamples(outputSamples, (int)rate)); + } + catch (Exception ex) + { + foreach (Audio.SampleBuffer buffer in output) + buffer.Clear(); + Dispatcher.UIThread.Post(() => log.Text = ex.ToString()); + } + } + + private Audio.Device SelectedDevice() + { + Audio.Driver driver = Audio.Driver.Drivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? Audio.Driver.Drivers.First(); + return driver.Devices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? driver.Devices.First(); + } + + private static System.Collections.Generic.IEnumerable SelectedChannels(Audio.Channel[] channels, System.Collections.Generic.IEnumerable names) + { + System.Collections.Generic.HashSet selected = names.ToHashSet(StringComparer.OrdinalIgnoreCase); + return channels.Where(i => selected.Contains(i.Name)); + } + private static TextBlock Header(string text) { return new TextBlock { Text = text, FontWeight = FontWeight.Bold, Margin = new global::Avalonia.Thickness(0, 8, 0, 0) }; From e5184ccdb94a48804d4c88f756c77046c1a67b88 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 19:42:09 +0200 Subject: [PATCH 09/42] Open all schematic launch arguments --- LiveSPICE.Avalonia/MainWindow.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs index 7dae60b8..87a69c4f 100644 --- a/LiveSPICE.Avalonia/MainWindow.cs +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -255,12 +255,14 @@ protected override void OnOpened(EventArgs e) { base.OnOpened(e); - string? path = Environment.GetCommandLineArgs() + string[] paths = Environment.GetCommandLineArgs() .Skip(1) - .FirstOrDefault(i => i.EndsWith(".schx", StringComparison.OrdinalIgnoreCase) && File.Exists(i)); + .Where(i => i.EndsWith(".schx", StringComparison.OrdinalIgnoreCase) && File.Exists(i)) + .ToArray(); - if (path != null) - LoadSchematic(path); + if (paths.Length > 0) + foreach (string path in paths) + LoadSchematic(path); else NewDocument(); From 1797f2e34aba3b07e4e35a4a66f71d9e945e81b4 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 20:14:47 +0200 Subject: [PATCH 10/42] Add Avalonia editor interaction tests --- .../LiveSPICE.Avalonia.Tests.csproj | 13 ++ LiveSPICE.Avalonia.Tests/Program.cs | 153 ++++++++++++++++++ LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj | 5 + LiveSPICE.Avalonia/SchematicCanvas.cs | 74 +++++++++ LiveSPICE.sln | 6 + 5 files changed, 251 insertions(+) create mode 100644 LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj create mode 100644 LiveSPICE.Avalonia.Tests/Program.cs diff --git a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj new file mode 100644 index 00000000..43b938dd --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + LiveSPICE.Avalonia.Tests + + + + + + \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/Program.cs b/LiveSPICE.Avalonia.Tests/Program.cs new file mode 100644 index 00000000..f2cca28f --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/Program.cs @@ -0,0 +1,153 @@ +using Circuit; +using LiveSPICE.Avalonia; + +static class Program +{ + private static int failures; + + public static int Main() + { + Run("click selects a symbol", ClickSelectsSymbol); + Run("ctrl click toggles selection", ControlClickTogglesSelection); + Run("drag selected symbol records one undo", DragSelectedSymbolRecordsOneUndo); + Run("rectangle drag selects multiple elements", RectangleDragSelectsMultipleElements); + Run("delete selection is undoable", DeleteSelectionIsUndoable); + Run("wire clicks create undoable wire", WireClicksCreateUndoableWire); + Run("copy paste duplicates selection", CopyPasteDuplicatesSelection); + return failures == 0 ? 0 : 1; + } + + private static void ClickSelectsSymbol() + { + TestContext context = TestContext.Load(); + Symbol symbol = context.FirstSymbol; + Assert(context.Canvas.TestClick(Center(symbol)), "click should hit first symbol"); + Assert(context.Canvas.SelectedObjects.Single() == symbol.Component, "selected object should be symbol component"); + } + + private static void ControlClickTogglesSelection() + { + TestContext context = TestContext.Load(); + Symbol first = context.FirstSymbol; + Symbol second = context.SecondSymbol; + context.Canvas.TestClick(Center(first)); + context.Canvas.TestClick(Center(second), control: true); + Assert(context.Canvas.SelectedObjects.Count == 2, "ctrl-click should add second selection"); + context.Canvas.TestClick(Center(first), control: true); + Assert(context.Canvas.SelectedObjects.Count == 1, "second ctrl-click should remove existing selection"); + } + + private static void DragSelectedSymbolRecordsOneUndo() + { + TestContext context = TestContext.Load(); + Symbol symbol = context.FirstSymbol; + Coord before = symbol.Position; + Coord from = Center(symbol); + Coord to = from + new Coord(30, 0); + context.Canvas.TestClick(from); + context.Canvas.TestDragSelected(from, to); + Assert(symbol.Position == before + new Coord(30, 0), "drag should move selected symbol"); + Assert(context.Document.CanUndo, "drag should record undo"); + context.Document.Undo(); + Assert(symbol.Position == before, "undo should restore original position"); + context.Document.Redo(); + Assert(symbol.Position == before + new Coord(30, 0), "redo should reapply drag"); + } + + private static void RectangleDragSelectsMultipleElements() + { + TestContext context = TestContext.Load(); + Coord lower = context.Document.Schematic.LowerBound - new Coord(20, 20); + Coord upper = context.Document.Schematic.UpperBound + new Coord(20, 20); + context.Canvas.TestDragSelect(lower, upper); + Assert(context.Canvas.SelectedObjects.Count >= 2, "rectangle should select multiple objects"); + } + + private static void DeleteSelectionIsUndoable() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Elements.Count(); + context.Canvas.TestClick(Center(context.FirstSymbol)); + context.Canvas.DeleteSelection(); + Assert(context.Document.Schematic.Elements.Count() == before - 1, "delete should remove selected element"); + context.Document.Undo(); + Assert(context.Document.Schematic.Elements.Count() == before, "undo should restore deleted element"); + } + + private static void WireClicksCreateUndoableWire() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Wires.Count(); + Coord a = new Coord(-120, -80); + Coord b = new Coord(-60, -80); + Assert(!context.Canvas.TestWireClick(a), "first wire click should set start point"); + Assert(context.Canvas.TestWireClick(b), "second wire click should create wire"); + Assert(context.Document.Schematic.Wires.Count() == before + 1, "wire click pair should add a wire"); + Assert(context.Document.CanUndo, "wire creation should be undoable"); + context.Document.Undo(); + Assert(context.Document.Schematic.Wires.Count() == before, "undo should remove created wire"); + } + + private static void CopyPasteDuplicatesSelection() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Elements.Count(); + context.Canvas.TestClick(Center(context.FirstSymbol)); + string? xml = context.Canvas.CopySelectionXml(); + Assert(!string.IsNullOrWhiteSpace(xml), "copy should return serialized selection"); + Assert(context.Canvas.PasteSelectionXml(xml!), "paste should accept copied selection"); + Assert(context.Document.Schematic.Elements.Count() == before + 1, "paste should duplicate selected element"); + Assert(context.Canvas.SelectedObjects.Count == 1, "pasted element should become selected"); + context.Document.Undo(); + Assert(context.Document.Schematic.Elements.Count() == before, "undo should remove pasted element"); + } + + private static Coord Center(Element element) + { + return (element.LowerBound + element.UpperBound) / 2; + } + + private static void Run(string name, Action test) + { + try + { + test(); + Console.WriteLine($"PASS {name}"); + } + catch (Exception ex) + { + failures++; + Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); + } + } + + private static void Assert(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } + + private sealed class TestContext + { + private TestContext(SchematicDocument document, SchematicCanvas canvas) + { + Document = document; + Canvas = canvas; + } + + public SchematicDocument Document { get; } + + public SchematicCanvas Canvas { get; } + + public Symbol FirstSymbol => Document.Schematic.Symbols.First(); + + public Symbol SecondSymbol => Document.Schematic.Symbols.Skip(1).First(); + + public static TestContext Load() + { + SchematicDocument document = SchematicDocument.Open("Tests/Circuits/Passive 1stOrder Highpass RC.schx"); + SchematicCanvas canvas = new SchematicCanvas { Document = document }; + return new TestContext(document, canvas); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj index d8e9e662..932fac5f 100644 --- a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj +++ b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj @@ -7,6 +7,11 @@ LiveSPICE.Avalonia LiveSPICE.Avalonia + + + <_Parameter1>LiveSPICE.Avalonia.Tests + + diff --git a/LiveSPICE.Avalonia/SchematicCanvas.cs b/LiveSPICE.Avalonia/SchematicCanvas.cs index 739a202d..84aaa898 100644 --- a/LiveSPICE.Avalonia/SchematicCanvas.cs +++ b/LiveSPICE.Avalonia/SchematicCanvas.cs @@ -453,6 +453,80 @@ public void BeginWireTool() wireStart = null; } + internal bool TestClick(Coord at, bool control = false) + { + Element? hit = HitTest(at); + if (hit == null) + { + if (!control) + ClearSelection(); + return false; + } + + if (control) + { + if (!selected.Add(hit)) + selected.Remove(hit); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + else + { + SetSelection(hit); + } + return true; + } + + internal void TestDragSelected(Coord from, Coord to) + { + if (selected.Count == 0) + TestClick(from); + if (selected.Count == 0) + return; + + Element[] moved = selected.ToArray(); + Coord delta = to - from; + if (delta == new Coord(0, 0)) + return; + + foreach (Element element in moved) + element.Move(delta); + document?.Record(new MoveElementsAction(moved, delta)); + MarkChanged(false); + } + + internal void TestDragSelect(Coord a, Coord b, bool control = false) + { + selectionBase = control ? selected.ToArray() : Array.Empty(); + HighlightRect(a, b); + selectionBase = Array.Empty(); + InvalidateVisual(); + } + + internal bool TestWireClick(Coord at) + { + if (schematic == null) + return false; + + if (!wireStart.HasValue) + { + BeginWireTool(); + wireStart = at; + return false; + } + + if (wireStart.Value == at) + return false; + + Wire wire = new Wire(wireStart.Value, at); + document?.Do(new AddElementsAction(schematic, new[] { wire })); + SetSelection(wire); + wireStart = null; + wireMode = false; + MarkChanged(false); + return true; + } + public void DeleteSelection() { if (schematic == null || selected.Count == 0) diff --git a/LiveSPICE.sln b/LiveSPICE.sln index 88251977..450a1f72 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -40,6 +40,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia", "LiveSPICE.Avalonia\LiveSPICE.Avalonia.csproj", "{4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia.Tests", "LiveSPICE.Avalonia.Tests\LiveSPICE.Avalonia.Tests.csproj", "{632F80C2-2019-4EF9-902B-ADE2977CC281}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -106,6 +108,10 @@ Global {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Release|Any CPU.Build.0 = Release|Any CPU + {632F80C2-2019-4EF9-902B-ADE2977CC281}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {632F80C2-2019-4EF9-902B-ADE2977CC281}.Debug|Any CPU.Build.0 = Debug|Any CPU + {632F80C2-2019-4EF9-902B-ADE2977CC281}.Release|Any CPU.ActiveCfg = Release|Any CPU + {632F80C2-2019-4EF9-902B-ADE2977CC281}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 6b4b0358a924b546ef71e68057c55ee49fc5bc69 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 20:16:05 +0200 Subject: [PATCH 11/42] Show named waveform output traces --- LiveSPICE.Avalonia/WaveformWindow.cs | 71 ++++++++++++++++++---------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index f2194c01..7ff7c607 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -120,16 +120,22 @@ private void RunSimulation() double inGain = DbToLinear(inputGain.Value); double outGain = DbToLinear(outputGain.Value); double[] input = new double[sampleCount]; - double[] output = new double[sampleCount]; + string[] outputNames = settings.AudioOutputs.Count == 0 ? new[] { "Speaker output" } : settings.AudioOutputs.ToArray(); + double[][] outputs = outputNames.Select(_ => new double[sampleCount]).ToArray(); for (int i = 0; i < input.Length; i++) input[i] = inGain * 0.25 * Math.Sin(2 * Math.PI * frequencyValue * i / sampleRate); - simulation.Run(input.Length, new[] { input }, new[] { output }); - for (int i = 0; i < output.Length; i++) - output[i] *= outGain; + simulation.Run(input.Length, new[] { input }, new[] { outputs[0] }); + for (int channel = 0; channel < outputs.Length; channel++) + { + if (channel > 0) + Array.Copy(outputs[0], outputs[channel], outputs[0].Length); + for (int i = 0; i < outputs[channel].Length; i++) + outputs[channel][i] *= outGain; + } - waveform.SetSamples(output, sampleRate); - log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {output.Select(Math.Abs).DefaultIfEmpty().Max():R}"; + waveform.SetTraces(outputNames.Zip(outputs, (name, data) => new WaveformTrace(name, data)), sampleRate); + log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {outputs.SelectMany(i => i).Select(Math.Abs).DefaultIfEmpty().Max():R}"; } catch (Exception ex) { @@ -295,12 +301,18 @@ private static double DbToLinear(double db) public sealed class WaveformView : Control { private static readonly Typeface TextTypeface = new Typeface("Inter"); - private double[] samples = Array.Empty(); + private static readonly IBrush[] TraceBrushes = { Brushes.DodgerBlue, Brushes.OrangeRed, Brushes.SeaGreen, Brushes.MediumPurple }; + private WaveformTrace[] traces = Array.Empty(); private int sampleRate = 48000; public void SetSamples(double[] value, int rate) { - samples = value; + SetTraces(new[] { new WaveformTrace("Output", value) }, rate); + } + + public void SetTraces(System.Collections.Generic.IEnumerable value, int rate) + { + traces = value.ToArray(); sampleRate = rate; InvalidateVisual(); } @@ -312,35 +324,46 @@ public override void Render(DrawingContext context) context.DrawRectangle(null, new Pen(Brushes.LightGray, 1), plot); context.DrawLine(new Pen(Brushes.Gray, 1), new APoint(plot.Left, plot.Center.Y), new APoint(plot.Right, plot.Center.Y)); - if (samples.Length == 0) + if (traces.Length == 0 || traces.All(i => i.Samples.Length == 0)) return; - double peak = Math.Max(samples.Select(Math.Abs).DefaultIfEmpty().Max(), 1e-9); - Pen waveform = new Pen(Brushes.DodgerBlue, 1.4); - APoint previous = SamplePoint(0, peak, plot); - for (int i = 1; i < samples.Length; i++) + double peak = Math.Max(traces.SelectMany(i => i.Samples).Select(Math.Abs).DefaultIfEmpty().Max(), 1e-9); + int maxSamples = traces.Max(i => i.Samples.Length); + for (int traceIndex = 0; traceIndex < traces.Length; traceIndex++) { - APoint next = SamplePoint(i, peak, plot); - context.DrawLine(waveform, previous, next); - previous = next; + double[] samples = traces[traceIndex].Samples; + if (samples.Length == 0) + continue; + + Pen waveform = new Pen(TraceBrushes[traceIndex % TraceBrushes.Length], 1.4); + APoint previous = SamplePoint(samples, 0, peak, plot); + for (int i = 1; i < samples.Length; i++) + { + APoint next = SamplePoint(samples, i, peak, plot); + context.DrawLine(waveform, previous, next); + previous = next; + } + DrawText(context, traces[traceIndex].Name, new APoint(plot.Right - 130, plot.Top + traceIndex * 16), TraceBrushes[traceIndex % TraceBrushes.Length]); } - DrawText(context, $"{samples.Length / (double)sampleRate:0.000}s peak {peak:0.000000}", new APoint(48, 6)); - DrawText(context, "+peak", new APoint(6, plot.Top)); - DrawText(context, "0", new APoint(24, plot.Center.Y - 8)); - DrawText(context, "-peak", new APoint(6, plot.Bottom - 16)); + DrawText(context, $"{maxSamples / (double)sampleRate:0.000}s peak {peak:0.000000}", new APoint(48, 6), Brushes.DimGray); + DrawText(context, "+peak", new APoint(6, plot.Top), Brushes.DimGray); + DrawText(context, "0", new APoint(24, plot.Center.Y - 8), Brushes.DimGray); + DrawText(context, "-peak", new APoint(6, plot.Bottom - 16), Brushes.DimGray); } - private APoint SamplePoint(int index, double peak, Rect plot) + private static APoint SamplePoint(double[] samples, int index, double peak, Rect plot) { double x = plot.Left + (samples.Length == 1 ? 0 : index * plot.Width / (samples.Length - 1)); double y = plot.Center.Y - samples[index] / peak * plot.Height / 2; return new APoint(x, y); } - private static void DrawText(DrawingContext context, string text, APoint point) + private static void DrawText(DrawingContext context, string text, APoint point, IBrush brush) { - FormattedText formatted = new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, 12, Brushes.DimGray); + FormattedText formatted = new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, 12, brush); context.DrawText(formatted, point); } -} \ No newline at end of file +} + +public sealed record WaveformTrace(string Name, double[] Samples); \ No newline at end of file From 9c9998e92ade6adb5237c3b269aed659adedd493 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 20:25:41 +0200 Subject: [PATCH 12/42] Fix Avalonia menu open and schematic layout --- Circuit/Components/Resistor.cs | 4 +- .../LiveSPICE.Avalonia.Tests.csproj | 14 +- LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs | 19 ++ LiveSPICE.Avalonia.Tests/Program.cs | 153 --------------- .../SchematicCanvasInteractionTests.cs | 185 ++++++++++++++++++ LiveSPICE.Avalonia/MainWindow.cs | 18 +- LiveSPICE.Avalonia/SchematicCanvas.cs | 25 ++- 7 files changed, 257 insertions(+), 161 deletions(-) create mode 100644 LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs delete mode 100644 LiveSPICE.Avalonia.Tests/Program.cs create mode 100644 LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs diff --git a/Circuit/Components/Resistor.cs b/Circuit/Components/Resistor.cs index fd255f30..a97a983f 100644 --- a/Circuit/Components/Resistor.cs +++ b/Circuit/Components/Resistor.cs @@ -59,8 +59,8 @@ protected internal override void LayoutSymbol(SymbolLayout Sym) Draw(Sym, 0, -16, 16, 7); - Sym.DrawText(() => Name, new Coord(6, 0), Alignment.Near, Alignment.Center); - Sym.DrawText(() => resistance.ToString(), new Coord(-6, 0), Alignment.Far, Alignment.Center); + Sym.DrawText(() => Name, new Coord(14, 0), Alignment.Near, Alignment.Center); + Sym.DrawText(() => resistance.ToString(), new Coord(-14, 0), Alignment.Far, Alignment.Center); } } } diff --git a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj index 43b938dd..b6792921 100644 --- a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj +++ b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj @@ -1,11 +1,23 @@ - Exe net8.0 enable enable LiveSPICE.Avalonia.Tests + false + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs b/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs new file mode 100644 index 00000000..1c3d8563 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs @@ -0,0 +1,19 @@ +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class MenuOpenPathTests +{ + [Fact] + public void OpenPathDecodesFileUriWithSpaces() + { + Assert.Equal("/tmp/MXR Phase 90.schx", MainWindow.OpenPath(null, new Uri("file:///tmp/MXR%20Phase%2090.schx"))); + } + + [Fact] + public void OpenPathPrefersLocalPathWhenAvailable() + { + Assert.Equal("/chosen/MXR Phase 90.schx", MainWindow.OpenPath("/chosen/MXR Phase 90.schx", new Uri("file:///tmp/MXR%20Phase%2090.schx"))); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/Program.cs b/LiveSPICE.Avalonia.Tests/Program.cs deleted file mode 100644 index f2cca28f..00000000 --- a/LiveSPICE.Avalonia.Tests/Program.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Circuit; -using LiveSPICE.Avalonia; - -static class Program -{ - private static int failures; - - public static int Main() - { - Run("click selects a symbol", ClickSelectsSymbol); - Run("ctrl click toggles selection", ControlClickTogglesSelection); - Run("drag selected symbol records one undo", DragSelectedSymbolRecordsOneUndo); - Run("rectangle drag selects multiple elements", RectangleDragSelectsMultipleElements); - Run("delete selection is undoable", DeleteSelectionIsUndoable); - Run("wire clicks create undoable wire", WireClicksCreateUndoableWire); - Run("copy paste duplicates selection", CopyPasteDuplicatesSelection); - return failures == 0 ? 0 : 1; - } - - private static void ClickSelectsSymbol() - { - TestContext context = TestContext.Load(); - Symbol symbol = context.FirstSymbol; - Assert(context.Canvas.TestClick(Center(symbol)), "click should hit first symbol"); - Assert(context.Canvas.SelectedObjects.Single() == symbol.Component, "selected object should be symbol component"); - } - - private static void ControlClickTogglesSelection() - { - TestContext context = TestContext.Load(); - Symbol first = context.FirstSymbol; - Symbol second = context.SecondSymbol; - context.Canvas.TestClick(Center(first)); - context.Canvas.TestClick(Center(second), control: true); - Assert(context.Canvas.SelectedObjects.Count == 2, "ctrl-click should add second selection"); - context.Canvas.TestClick(Center(first), control: true); - Assert(context.Canvas.SelectedObjects.Count == 1, "second ctrl-click should remove existing selection"); - } - - private static void DragSelectedSymbolRecordsOneUndo() - { - TestContext context = TestContext.Load(); - Symbol symbol = context.FirstSymbol; - Coord before = symbol.Position; - Coord from = Center(symbol); - Coord to = from + new Coord(30, 0); - context.Canvas.TestClick(from); - context.Canvas.TestDragSelected(from, to); - Assert(symbol.Position == before + new Coord(30, 0), "drag should move selected symbol"); - Assert(context.Document.CanUndo, "drag should record undo"); - context.Document.Undo(); - Assert(symbol.Position == before, "undo should restore original position"); - context.Document.Redo(); - Assert(symbol.Position == before + new Coord(30, 0), "redo should reapply drag"); - } - - private static void RectangleDragSelectsMultipleElements() - { - TestContext context = TestContext.Load(); - Coord lower = context.Document.Schematic.LowerBound - new Coord(20, 20); - Coord upper = context.Document.Schematic.UpperBound + new Coord(20, 20); - context.Canvas.TestDragSelect(lower, upper); - Assert(context.Canvas.SelectedObjects.Count >= 2, "rectangle should select multiple objects"); - } - - private static void DeleteSelectionIsUndoable() - { - TestContext context = TestContext.Load(); - int before = context.Document.Schematic.Elements.Count(); - context.Canvas.TestClick(Center(context.FirstSymbol)); - context.Canvas.DeleteSelection(); - Assert(context.Document.Schematic.Elements.Count() == before - 1, "delete should remove selected element"); - context.Document.Undo(); - Assert(context.Document.Schematic.Elements.Count() == before, "undo should restore deleted element"); - } - - private static void WireClicksCreateUndoableWire() - { - TestContext context = TestContext.Load(); - int before = context.Document.Schematic.Wires.Count(); - Coord a = new Coord(-120, -80); - Coord b = new Coord(-60, -80); - Assert(!context.Canvas.TestWireClick(a), "first wire click should set start point"); - Assert(context.Canvas.TestWireClick(b), "second wire click should create wire"); - Assert(context.Document.Schematic.Wires.Count() == before + 1, "wire click pair should add a wire"); - Assert(context.Document.CanUndo, "wire creation should be undoable"); - context.Document.Undo(); - Assert(context.Document.Schematic.Wires.Count() == before, "undo should remove created wire"); - } - - private static void CopyPasteDuplicatesSelection() - { - TestContext context = TestContext.Load(); - int before = context.Document.Schematic.Elements.Count(); - context.Canvas.TestClick(Center(context.FirstSymbol)); - string? xml = context.Canvas.CopySelectionXml(); - Assert(!string.IsNullOrWhiteSpace(xml), "copy should return serialized selection"); - Assert(context.Canvas.PasteSelectionXml(xml!), "paste should accept copied selection"); - Assert(context.Document.Schematic.Elements.Count() == before + 1, "paste should duplicate selected element"); - Assert(context.Canvas.SelectedObjects.Count == 1, "pasted element should become selected"); - context.Document.Undo(); - Assert(context.Document.Schematic.Elements.Count() == before, "undo should remove pasted element"); - } - - private static Coord Center(Element element) - { - return (element.LowerBound + element.UpperBound) / 2; - } - - private static void Run(string name, Action test) - { - try - { - test(); - Console.WriteLine($"PASS {name}"); - } - catch (Exception ex) - { - failures++; - Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); - } - } - - private static void Assert(bool condition, string message) - { - if (!condition) - throw new InvalidOperationException(message); - } - - private sealed class TestContext - { - private TestContext(SchematicDocument document, SchematicCanvas canvas) - { - Document = document; - Canvas = canvas; - } - - public SchematicDocument Document { get; } - - public SchematicCanvas Canvas { get; } - - public Symbol FirstSymbol => Document.Schematic.Symbols.First(); - - public Symbol SecondSymbol => Document.Schematic.Symbols.Skip(1).First(); - - public static TestContext Load() - { - SchematicDocument document = SchematicDocument.Open("Tests/Circuits/Passive 1stOrder Highpass RC.schx"); - SchematicCanvas canvas = new SchematicCanvas { Document = document }; - return new TestContext(document, canvas); - } - } -} \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs new file mode 100644 index 00000000..4ab210d6 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs @@ -0,0 +1,185 @@ +using Circuit; +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class SchematicCanvasInteractionTests +{ + [Fact] + public void ClickSelectsSymbol() + { + TestContext context = TestContext.Load(); + Symbol symbol = context.FirstSymbol; + + Assert.True(context.Canvas.TestClick(Center(symbol))); + Assert.Same(symbol.Component, context.Canvas.SelectedObjects.Single()); + } + + [Fact] + public void ControlClickTogglesSelection() + { + TestContext context = TestContext.Load(); + Symbol first = context.FirstSymbol; + Symbol second = context.SecondSymbol; + + context.Canvas.TestClick(Center(first)); + context.Canvas.TestClick(Center(second), control: true); + Assert.Equal(2, context.Canvas.SelectedObjects.Count); + + context.Canvas.TestClick(Center(first), control: true); + Assert.Single(context.Canvas.SelectedObjects); + } + + [Fact] + public void DragSelectedSymbolRecordsOneUndo() + { + TestContext context = TestContext.Load(); + Symbol symbol = context.FirstSymbol; + Coord before = symbol.Position; + Coord from = Center(symbol); + Coord to = from + new Coord(30, 0); + + context.Canvas.TestClick(from); + context.Canvas.TestDragSelected(from, to); + + Assert.Equal(before + new Coord(30, 0), symbol.Position); + Assert.True(context.Document.CanUndo); + context.Document.Undo(); + Assert.Equal(before, symbol.Position); + context.Document.Redo(); + Assert.Equal(before + new Coord(30, 0), symbol.Position); + } + + [Fact] + public void RectangleDragSelectsMultipleElements() + { + TestContext context = TestContext.Load(); + Coord lower = context.Document.Schematic.LowerBound - new Coord(20, 20); + Coord upper = context.Document.Schematic.UpperBound + new Coord(20, 20); + + context.Canvas.TestDragSelect(lower, upper); + + Assert.True(context.Canvas.SelectedObjects.Count >= 2); + } + + [Fact] + public void DeleteSelectionIsUndoable() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Elements.Count(); + + context.Canvas.TestClick(Center(context.FirstSymbol)); + context.Canvas.DeleteSelection(); + + Assert.Equal(before - 1, context.Document.Schematic.Elements.Count()); + context.Document.Undo(); + Assert.Equal(before, context.Document.Schematic.Elements.Count()); + } + + [Fact] + public void WireClicksCreateUndoableWire() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Wires.Count(); + Coord a = new Coord(-120, -80); + Coord b = new Coord(-60, -80); + + Assert.False(context.Canvas.TestWireClick(a)); + Assert.True(context.Canvas.TestWireClick(b)); + + Assert.Equal(before + 1, context.Document.Schematic.Wires.Count()); + Assert.True(context.Document.CanUndo); + context.Document.Undo(); + Assert.Equal(before, context.Document.Schematic.Wires.Count()); + } + + [Fact] + public void CopyPasteDuplicatesSelection() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Elements.Count(); + + context.Canvas.TestClick(Center(context.FirstSymbol)); + string? xml = context.Canvas.CopySelectionXml(); + + Assert.False(string.IsNullOrWhiteSpace(xml)); + Assert.True(context.Canvas.PasteSelectionXml(xml!)); + Assert.Equal(before + 1, context.Document.Schematic.Elements.Count()); + Assert.Single(context.Canvas.SelectedObjects); + context.Document.Undo(); + Assert.Equal(before, context.Document.Schematic.Elements.Count()); + } + + [Fact] + public void DrawTransformMatchesSpeakerTerminals() + { + TestContext context = TestContext.Load(); + Symbol speaker = context.Document.Schematic.Symbols.Single(i => i.Component is Speaker); + + foreach (Terminal terminal in speaker.Terminals) + Assert.Equal(speaker.MapTerminal(terminal), SchematicCanvas.TestTransformPoint(speaker, speaker.Component.LayoutSymbol().MapTerminal(terminal))); + } + + [Fact] + public void DrawTransformMatchesGroundTerminal() + { + Symbol ground = new Symbol(new Ground()) { Position = new Coord(120, -40), Rotation = 1 }; + Terminal terminal = ground.Terminals.Single(); + + Assert.Equal(ground.MapTerminal(terminal), SchematicCanvas.TestTransformPoint(ground, ground.Component.LayoutSymbol().MapTerminal(terminal))); + } + + [Fact] + public void ResistorValueAndNameTextAreOutsideBody() + { + SymbolLayout layout = new Resistor().LayoutSymbol(); + SymbolLayout.Text name = layout.Texts.Single(i => i.String == "R1"); + SymbolLayout.Text value = layout.Texts.Single(i => i.String.Contains("Ω")); + + Assert.True(name.x.x >= 12); + Assert.True(value.x.x <= -12); + } + + private static Coord Center(Element element) + { + return (element.LowerBound + element.UpperBound) / 2; + } + + private sealed class TestContext + { + private TestContext(SchematicDocument document, SchematicCanvas canvas) + { + Document = document; + Canvas = canvas; + } + + public SchematicDocument Document { get; } + + public SchematicCanvas Canvas { get; } + + public Symbol FirstSymbol => Document.Schematic.Symbols.First(); + + public Symbol SecondSymbol => Document.Schematic.Symbols.Skip(1).First(); + + public static TestContext Load() + { + SchematicDocument document = SchematicDocument.Open(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + SchematicCanvas canvas = new SchematicCanvas { Document = document }; + return new TestContext(document, canvas); + } + + private static string FindFixture(string relativePath) + { + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException("Could not locate test fixture.", relativePath); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs index 87a69c4f..52243562 100644 --- a/LiveSPICE.Avalonia/MainWindow.cs +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -306,10 +306,26 @@ private async System.Threading.Tasks.Task OpenSchematicAsync() } }); - foreach (string path in files.Select(i => i.TryGetLocalPath()).Where(i => i != null).Cast()) + foreach (string path in files.Select(OpenPath).Where(i => i != null).Cast()) LoadSchematic(path); } + internal static string? OpenPath(IStorageFile file) + { + return OpenPath(file.TryGetLocalPath(), file.Path); + } + + internal static string? OpenPath(string? localPath, Uri uri) + { + if (!string.IsNullOrWhiteSpace(localPath)) + return localPath; + + if (uri.IsFile) + return uri.LocalPath; + + return null; + } + private void LoadSchematic(string path) { try diff --git a/LiveSPICE.Avalonia/SchematicCanvas.cs b/LiveSPICE.Avalonia/SchematicCanvas.cs index 84aaa898..2fd0d2ff 100644 --- a/LiveSPICE.Avalonia/SchematicCanvas.cs +++ b/LiveSPICE.Avalonia/SchematicCanvas.cs @@ -246,7 +246,20 @@ private void DrawText(DrawingContext context, Transform transform, SymbolLayout. }; FormattedText formatted = new FormattedText(text.String, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, size * Zoom, Brushes.Black); APoint point = ToScreen(transform.Apply(text.x)); - context.DrawText(formatted, point); + double x = point.X - formatted.Width * AlignmentFactor(text.HorizontalAlign); + double y = point.Y - formatted.Height * AlignmentFactor(text.VerticalAlign); + context.DrawText(formatted, new APoint(x, y)); + } + + private static double AlignmentFactor(Alignment alignment) + { + return alignment switch + { + Alignment.Near => 0, + Alignment.Center => 0.5, + Alignment.Far => 1, + _ => 0 + }; } private void DrawTerminal(DrawingContext context, APoint point, IBrush brush) @@ -527,6 +540,11 @@ internal bool TestWireClick(Coord at) return true; } + internal static Coord TestTransformPoint(Symbol symbol, Circuit.Point point) + { + return (Coord)Circuit.Point.Round(new Transform(symbol, symbol.Component.LayoutSymbol()).Apply(point)); + } + public void DeleteSelection() { if (schematic == null || selected.Count == 0) @@ -690,9 +708,8 @@ public Transform(Symbol symbol, SymbolLayout layout) public Circuit.Point Apply(Circuit.Point local) { - Circuit.Coord offset = (layout.LowerBound + layout.UpperBound) / 2; - double x = local.x - offset.x; - double y = local.y - offset.y; + double x = local.x; + double y = local.y; if (!symbol.Flip) y = -y; From 328b12d7f33bd347bc8f754fc50897bb0200f230 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 20:29:50 +0200 Subject: [PATCH 13/42] Expand Avalonia coverage suite --- LiveSPICE.Avalonia.Tests/AppSettingsTests.cs | 59 ++++++++++++++ LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs | 15 ++++ .../SimulationAndAudioTests.cs | 79 +++++++++++++++++++ LiveSPICE.Avalonia/AppSettings.cs | 18 ++++- LiveSPICE.Avalonia/MainWindow.cs | 15 ++++ 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 LiveSPICE.Avalonia.Tests/AppSettingsTests.cs create mode 100644 LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs diff --git a/LiveSPICE.Avalonia.Tests/AppSettingsTests.cs b/LiveSPICE.Avalonia.Tests/AppSettingsTests.cs new file mode 100644 index 00000000..f06425be --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/AppSettingsTests.cs @@ -0,0 +1,59 @@ +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class AppSettingsTests +{ + [Fact] + public void SaveAndLoadRoundTripsWindowRecentAndAudioSettings() + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "settings.json"); + AppSettings settings = new AppSettings + { + WindowWidth = 1400, + WindowHeight = 900, + AudioDriver = "Virtual Audio", + AudioDevice = "Managed loopback", + AudioInputs = new List { "Input 1" }, + AudioOutputs = new List { "Output 1" } + }; + settings.MarkUsed("Tests/Examples/MXR Phase 90.schx"); + + settings.Save(path); + AppSettings loaded = AppSettings.Load(path); + + Assert.Equal(1400, loaded.WindowWidth); + Assert.Equal(900, loaded.WindowHeight); + Assert.Equal("Virtual Audio", loaded.AudioDriver); + Assert.Equal("Managed loopback", loaded.AudioDevice); + Assert.Equal("Input 1", Assert.Single(loaded.AudioInputs)); + Assert.Equal("Output 1", Assert.Single(loaded.AudioOutputs)); + Assert.EndsWith("MXR Phase 90.schx", Assert.Single(loaded.RecentFiles)); + } + + [Fact] + public void MarkUsedDeduplicatesAndCapsRecentFiles() + { + AppSettings settings = new AppSettings(); + + for (int i = 0; i < 25; i++) + settings.MarkUsed($"file-{i}.schx"); + settings.MarkUsed("file-10.schx"); + + Assert.Equal(20, settings.RecentFiles.Count); + Assert.EndsWith("file-10.schx", settings.RecentFiles[0]); + Assert.Equal(settings.RecentFiles.Count, settings.RecentFiles.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public void ExistingRecentFilesFiltersMissingEntries() + { + string existing = Path.GetTempFileName(); + AppSettings settings = new AppSettings(); + settings.MarkUsed("missing-file.schx"); + settings.MarkUsed(existing); + + Assert.Equal(existing, Assert.Single(settings.ExistingRecentFiles())); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs b/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs index 1c3d8563..5196577e 100644 --- a/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs +++ b/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs @@ -16,4 +16,19 @@ public void OpenPathPrefersLocalPathWhenAvailable() { Assert.Equal("/chosen/MXR Phase 90.schx", MainWindow.OpenPath("/chosen/MXR Phase 90.schx", new Uri("file:///tmp/MXR%20Phase%2090.schx"))); } + + [Fact] + public void UntouchedStartupDocumentCanBeReplacedByFirstOpen() + { + Assert.True(MainWindow.IsUntouchedStartupDocument(SchematicDocument.New())); + } + + [Fact] + public void DirtyStartupDocumentIsNotReplacedByFirstOpen() + { + SchematicDocument document = SchematicDocument.New(); + document.MarkDirty(); + + Assert.False(MainWindow.IsUntouchedStartupDocument(document)); + } } \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs new file mode 100644 index 00000000..f9f91114 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -0,0 +1,79 @@ +using Circuit; +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class SimulationAndAudioTests +{ + [Fact] + public void AudioSimulationFactoryRunsPassiveHighpass() + { + Circuit.Circuit circuit = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")).Build(); + Simulation simulation = AudioSimulationFactory.Create(circuit, 48000, 4, 8); + double[] input = Enumerable.Range(0, 512).Select(i => Math.Sin(2 * Math.PI * 440 * i / 48000)).ToArray(); + double[] output = new double[input.Length]; + + simulation.Run(input.Length, new[] { input }, new[] { output }); + + Assert.Contains(output, i => Math.Abs(i) > 1e-9); + Assert.DoesNotContain(output, double.IsNaN); + } + + [Fact] + public void AudioSimulationFactoryRejectsCircuitWithoutInput() + { + Circuit.Circuit circuit = new Circuit.Circuit(); + + Assert.Throws(() => AudioSimulationFactory.Create(circuit, 48000, 4, 8)); + } + + [Fact] + public void VirtualAudioDriverProvidesLoopbackDeviceAndChannels() + { + VirtualAudioDriver driver = new VirtualAudioDriver(); + Audio.Device device = driver.Devices.Single(); + + Assert.Equal("Virtual Audio", driver.Name); + Assert.Equal("Managed loopback", device.Name); + Assert.Single(device.InputChannels); + Assert.Single(device.OutputChannels); + } + + [Fact] + public void VirtualAudioStreamInvokesCallbackUntilStopped() + { + VirtualAudioDriver driver = new VirtualAudioDriver(); + Audio.Device device = driver.Devices.Single(); + using ManualResetEventSlim called = new ManualResetEventSlim(false); + int callbackCount = 0; + + Audio.Stream stream = device.Open((count, input, output, rate) => + { + callbackCount++; + Assert.Equal(256, count); + Assert.Equal(48000, rate); + Assert.Single(input); + Assert.Single(output); + output[0][0] = 0.25; + called.Set(); + }, device.InputChannels, device.OutputChannels); + + Assert.True(called.Wait(TimeSpan.FromSeconds(2))); + stream.Stop(); + Assert.True(callbackCount > 0); + } + + private static string FindFixture(string relativePath) + { + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException("Could not locate test fixture.", relativePath); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/AppSettings.cs b/LiveSPICE.Avalonia/AppSettings.cs index cec2273b..d78c305d 100644 --- a/LiveSPICE.Avalonia/AppSettings.cs +++ b/LiveSPICE.Avalonia/AppSettings.cs @@ -34,11 +34,16 @@ public static string SettingsPath } public static AppSettings Load() + { + return Load(SettingsPath); + } + + internal static AppSettings Load(string path) { try { - if (File.Exists(SettingsPath)) - return JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) ?? new AppSettings(); + if (File.Exists(path)) + return JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new AppSettings(); } catch { @@ -49,8 +54,13 @@ public static AppSettings Load() public void Save() { - Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath) ?? "."); - File.WriteAllText(SettingsPath, JsonSerializer.Serialize(this, Options)); + Save(SettingsPath); + } + + internal void Save(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + File.WriteAllText(path, JsonSerializer.Serialize(this, Options)); } public void MarkUsed(string path) diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs index 52243562..16bb3218 100644 --- a/LiveSPICE.Avalonia/MainWindow.cs +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -340,6 +340,7 @@ private void LoadSchematic(string path) return; } + ReplaceUntouchedStartupDocument(); AddDocument(SchematicDocument.Open(path)); settings.MarkUsed(path); RefreshRecentFilesMenu(); @@ -351,6 +352,20 @@ private void LoadSchematic(string path) } } + private void ReplaceUntouchedStartupDocument() + { + if (documents.Items.Count != 1) + return; + + if (documents.Items[0] is TabItem { Tag: SchematicDocument document } tab && IsUntouchedStartupDocument(document)) + documents.Items.Remove(tab); + } + + internal static bool IsUntouchedStartupDocument(SchematicDocument document) + { + return document.FilePath == null && !document.Dirty && !document.Schematic.Elements.Any(); + } + private void NewDocument() { AddDocument(SchematicDocument.New()); From b740084d7868c97f697e0a49b8385989ee09f21f Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 20:47:45 +0200 Subject: [PATCH 14/42] Add Linux plugin core and audio backend --- .../LiveSPICE.Avalonia.Tests.csproj | 2 + LiveSPICE.Avalonia.Tests/PluginPortTests.cs | 55 ++++ .../SimulationAndAudioTests.cs | 26 ++ LiveSPICE.Avalonia/LinuxAudioDriver.cs | 266 ++++++++++++++++++ LiveSPICE.Avalonia/LiveAudioProcessor.cs | 68 +++++ LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj | 2 + LiveSPICE.Avalonia/PluginEditorWindow.cs | 243 ++++++++++++++++ LiveSPICE.Avalonia/WaveformWindow.cs | 74 ++--- .../LiveSPICE.PluginCore.csproj | 13 + .../PluginProgramParameters.cs | 76 +++++ LiveSPICE.PluginCore/SimulationProcessor.cs | 253 +++++++++++++++++ .../Wrappers/ComponentWrapper.cs | 25 ++ .../Wrappers/DoubleThrowWrapper.cs | 28 ++ .../Wrappers/IComponentWrapper.cs | 10 + .../Wrappers/MultiThrowWrapper.cs | 25 ++ LiveSPICE.PluginCore/Wrappers/PotWrapper.cs | 25 ++ .../LiveSPICE.PluginLinux.csproj | 16 ++ LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs | 117 ++++++++ LiveSPICE.sln | 12 + LiveSPICEVst/EditorView.xaml.cs | 1 + LiveSPICEVst/LiveSPICEPlugin.cs | 59 +--- LiveSPICEVst/LiveSPICEVst.csproj | 5 + LiveSPICEVst/SimulationInterface.xaml | 5 +- LiveSPICEVst/SimulationInterface.xaml.cs | 1 + 24 files changed, 1308 insertions(+), 99 deletions(-) create mode 100644 LiveSPICE.Avalonia.Tests/PluginPortTests.cs create mode 100644 LiveSPICE.Avalonia/LinuxAudioDriver.cs create mode 100644 LiveSPICE.Avalonia/LiveAudioProcessor.cs create mode 100644 LiveSPICE.Avalonia/PluginEditorWindow.cs create mode 100644 LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj create mode 100644 LiveSPICE.PluginCore/PluginProgramParameters.cs create mode 100644 LiveSPICE.PluginCore/SimulationProcessor.cs create mode 100644 LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs create mode 100644 LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs create mode 100644 LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs create mode 100644 LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs create mode 100644 LiveSPICE.PluginCore/Wrappers/PotWrapper.cs create mode 100644 LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj create mode 100644 LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs diff --git a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj index b6792921..014beae9 100644 --- a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj +++ b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj @@ -20,6 +20,8 @@ + + \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs new file mode 100644 index 00000000..45d3a081 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -0,0 +1,55 @@ +using AudioPlugSharp; +using LiveSPICE.PluginCore; +using LiveSPICE.PluginLinux; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public class PluginPortTests +{ + [Fact] + public void LinuxPluginInitializesMonoPorts() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + + plugin.Initialize(); + + Assert.Single(plugin.InputPorts); + Assert.Single(plugin.OutputPorts); + Assert.Equal(EAudioChannelConfiguration.Mono, plugin.InputPorts[0].ChannelConfiguration); + Assert.Equal(EAudioChannelConfiguration.Mono, plugin.OutputPorts[0].ChannelConfiguration); + Assert.False(plugin.HasUserInterface); + } + + [Fact] + public void PluginProgramParametersRoundTripProcessorSettings() + { + SimulationProcessor processor = new SimulationProcessor + { + Oversample = 4, + Iterations = 16, + }; + + PluginProgramParameters parameters = PluginProgramParameters.FromProcessor(processor); + SimulationProcessor restored = new SimulationProcessor(); + parameters.ApplyTo(restored); + + Assert.Equal(4, restored.Oversample); + Assert.Equal(16, restored.Iterations); + } + + [Fact] + public void LinuxPluginStateRoundTripsProcessorSettings() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + plugin.SimulationProcessor.Oversample = 8; + plugin.SimulationProcessor.Iterations = 32; + + byte[] state = plugin.SaveState(); + LiveSPICELinuxPlugin restored = new LiveSPICELinuxPlugin(); + restored.RestoreState(state); + + Assert.Equal(8, restored.SimulationProcessor.Oversample); + Assert.Equal(32, restored.SimulationProcessor.Iterations); + } +} diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index f9f91114..0ef7673b 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -40,6 +40,16 @@ public void VirtualAudioDriverProvidesLoopbackDeviceAndChannels() Assert.Single(device.OutputChannels); } + [Fact] + public void LinuxAudioDiscoveryFiltersNonAudioPorts() + { + LinuxAudioDriver driver = new LinuxAudioDriver(); + + Assert.Equal("PipeWire/JACK", driver.Name); + foreach (Audio.Device device in driver.Devices) + Assert.DoesNotContain(device.InputChannels.Concat(device.OutputChannels), i => i.Name.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void VirtualAudioStreamInvokesCallbackUntilStopped() { @@ -64,6 +74,22 @@ public void VirtualAudioStreamInvokesCallbackUntilStopped() Assert.True(callbackCount > 0); } + [Fact] + public async Task LiveAudioProcessorCanRunOffUiThread() + { + Schematic schematic = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + LiveAudioProcessor processor = new LiveAudioProcessor(schematic); + processor.Start(48000, 8, 8); + using Audio.SampleBuffer input = new Audio.SampleBuffer(128); + using Audio.SampleBuffer output = new Audio.SampleBuffer(128); + for (int i = 0; i < input.Samples.Length; i++) + input[i] = Math.Sin(2 * Math.PI * 440 * i / 48000); + + await Task.Run(() => processor.Process(128, new[] { input }, new[] { output }, 48000)); + + Assert.Contains(output.Samples, i => Math.Abs(i) > 1e-12); + } + private static string FindFixture(string relativePath) { DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/LiveSPICE.Avalonia/LinuxAudioDriver.cs b/LiveSPICE.Avalonia/LinuxAudioDriver.cs new file mode 100644 index 00000000..48459d19 --- /dev/null +++ b/LiveSPICE.Avalonia/LinuxAudioDriver.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; + +namespace LiveSPICE.Avalonia; + +public sealed class LinuxAudioDriver : Audio.Driver +{ + public LinuxAudioDriver() + { + LinuxAudioChannel[] inputs = LinuxAudioDiscovery.InputPorts().Select(i => new LinuxAudioChannel(i)).ToArray(); + LinuxAudioChannel[] outputs = LinuxAudioDiscovery.OutputPorts().Select(i => new LinuxAudioChannel(i)).ToArray(); + if (inputs.Length > 0 || outputs.Length > 0) + devices.Add(new LinuxAudioDevice(inputs, outputs)); + } + + public override string Name => "PipeWire/JACK"; +} + +internal sealed class LinuxAudioChannel : Audio.Channel +{ + public LinuxAudioChannel(string name) + { + Name = name; + } + + public override string Name { get; } + + public override string ToString() + { + return Name; + } +} + +internal sealed class LinuxAudioDevice : Audio.Device +{ + public LinuxAudioDevice(Audio.Channel[] input, Audio.Channel[] output) : base("System audio ports") + { + inputs = input; + outputs = output; + } + + public override Audio.Stream Open(Audio.Stream.SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) + { + return new JackAudioStream(callback, input.Cast().ToArray(), output.Cast().ToArray()); + } +} + +internal sealed unsafe class JackAudioStream : Audio.Stream +{ + private const uint JackNullOption = 0; + private const ulong JackPortIsInput = 1; + private const ulong JackPortIsOutput = 2; + private const string DefaultAudioType = "32 bit float mono audio"; + + private readonly SampleHandler callback; + private readonly IntPtr client; + private readonly IntPtr[] inputPorts; + private readonly IntPtr[] outputPorts; + private readonly Audio.SampleBuffer[] inputBuffers; + private readonly Audio.SampleBuffer[] outputBuffers; + private readonly JackProcessCallback processCallback; + private readonly double sampleRate; + private bool stopped; + + public JackAudioStream(SampleHandler callback, LinuxAudioChannel[] input, LinuxAudioChannel[] output) : base(input, output) + { + this.callback = callback; + IntPtr status; + client = JackNative.jack_client_open("LiveSPICE", JackNullOption, out status); + if (client == IntPtr.Zero) + throw new InvalidOperationException("Could not open JACK client. Is JACK or PipeWire JACK running?"); + + sampleRate = JackNative.jack_get_sample_rate(client); + inputPorts = new IntPtr[input.Length]; + outputPorts = new IntPtr[output.Length]; + inputBuffers = input.Select(_ => new Audio.SampleBuffer(1)).ToArray(); + outputBuffers = output.Select(_ => new Audio.SampleBuffer(1)).ToArray(); + + for (int i = 0; i < inputPorts.Length; i++) + inputPorts[i] = RegisterPort($"input_{i + 1}", JackPortIsInput); + for (int i = 0; i < outputPorts.Length; i++) + outputPorts[i] = RegisterPort($"output_{i + 1}", JackPortIsOutput); + + processCallback = Process; + JackNative.Check(JackNative.jack_set_process_callback(client, processCallback, IntPtr.Zero), "set JACK process callback"); + JackNative.Check(JackNative.jack_activate(client), "activate JACK client"); + + for (int i = 0; i < input.Length; i++) + JackNative.jack_connect(client, input[i].Name, JackNative.jack_port_name(inputPorts[i])); + for (int i = 0; i < output.Length; i++) + JackNative.jack_connect(client, JackNative.jack_port_name(outputPorts[i]), output[i].Name); + } + + public override double SampleRate => sampleRate; + + public override void Stop() + { + if (stopped) + return; + + stopped = true; + JackNative.jack_deactivate(client); + JackNative.jack_client_close(client); + foreach (Audio.SampleBuffer buffer in inputBuffers.Concat(outputBuffers)) + buffer.Dispose(); + } + + private IntPtr RegisterPort(string name, ulong flags) + { + IntPtr port = JackNative.jack_port_register(client, name, DefaultAudioType, flags, 0); + if (port == IntPtr.Zero) + throw new InvalidOperationException($"Could not register JACK port '{name}'."); + return port; + } + + private int Process(uint frameCount, IntPtr arg) + { + if (stopped) + return 0; + + try + { + EnsureBuffers((int)frameCount); + for (int channel = 0; channel < inputPorts.Length; channel++) + { + float* source = (float*)JackNative.jack_port_get_buffer(inputPorts[channel], frameCount); + for (int sample = 0; sample < frameCount; sample++) + inputBuffers[channel][sample] = source[sample]; + } + + callback((int)frameCount, inputBuffers, outputBuffers, sampleRate); + + for (int channel = 0; channel < outputPorts.Length; channel++) + { + float* destination = (float*)JackNative.jack_port_get_buffer(outputPorts[channel], frameCount); + for (int sample = 0; sample < frameCount; sample++) + destination[sample] = (float)Math.Clamp(outputBuffers[channel][sample], -1, 1); + } + } + catch + { + for (int channel = 0; channel < outputPorts.Length; channel++) + { + float* destination = (float*)JackNative.jack_port_get_buffer(outputPorts[channel], frameCount); + for (int sample = 0; sample < frameCount; sample++) + destination[sample] = 0; + } + } + return 0; + } + + private void EnsureBuffers(int frameCount) + { + for (int i = 0; i < inputBuffers.Length; i++) + if (inputBuffers[i].Samples.Length != frameCount) + { + inputBuffers[i].Dispose(); + inputBuffers[i] = new Audio.SampleBuffer(frameCount); + } + for (int i = 0; i < outputBuffers.Length; i++) + if (outputBuffers[i].Samples.Length != frameCount) + { + outputBuffers[i].Dispose(); + outputBuffers[i] = new Audio.SampleBuffer(frameCount); + } + } +} + +internal delegate int JackProcessCallback(uint frames, IntPtr arg); + +internal static class JackNative +{ + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr jack_client_open(string clientName, uint options, out IntPtr status); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_client_close(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr jack_port_register(IntPtr client, string portName, string portType, ulong flags, ulong bufferSize); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_set_process_callback(IntPtr client, JackProcessCallback callback, IntPtr arg); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_activate(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_deactivate(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern uint jack_get_sample_rate(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr jack_port_get_buffer(IntPtr port, uint frames); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern string jack_port_name(IntPtr port); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_connect(IntPtr client, string sourcePort, string destinationPort); + + public static void Check(int result, string operation) + { + if (result != 0) + throw new InvalidOperationException($"Could not {operation}. JACK error code {result}."); + } +} + +internal static class LinuxAudioDiscovery +{ + public static IEnumerable InputPorts() + { + return AudioPorts("pw-link", "-o") + .Concat(AudioPorts("jack_lsp", null).Where(i => i.Contains(":capture_", StringComparison.OrdinalIgnoreCase))) + .Where(IsAudioPort) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i, StringComparer.OrdinalIgnoreCase); + } + + public static IEnumerable OutputPorts() + { + return AudioPorts("pw-link", "-i") + .Concat(AudioPorts("jack_lsp", null).Where(i => i.Contains(":playback_", StringComparison.OrdinalIgnoreCase))) + .Where(IsAudioPort) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i, StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable AudioPorts(string command, string? arguments) + { + try + { + ProcessStartInfo startInfo = new ProcessStartInfo(command, arguments ?? string.Empty) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using Process process = Process.Start(startInfo)!; + string output = process.StandardOutput.ReadToEnd(); + if (!process.WaitForExit(1000) || process.ExitCode != 0) + return Array.Empty(); + + return output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(i => i.Trim()) + .Where(i => i.Length > 0) + .ToArray(); + } + catch + { + return Array.Empty(); + } + } + + private static bool IsAudioPort(string port) + { + return !port.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase) + && !port.StartsWith("v4l2_input.", StringComparison.OrdinalIgnoreCase) + && !port.StartsWith("libcamera_input.", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LiveAudioProcessor.cs b/LiveSPICE.Avalonia/LiveAudioProcessor.cs new file mode 100644 index 00000000..ccd3f2dc --- /dev/null +++ b/LiveSPICE.Avalonia/LiveAudioProcessor.cs @@ -0,0 +1,68 @@ +using System; +using Circuit; + +namespace LiveSPICE.Avalonia; + +internal sealed class LiveAudioProcessor +{ + private readonly object sync = new object(); + private readonly Schematic schematic; + private Simulation? simulation; + + public LiveAudioProcessor(Schematic schematic) + { + this.schematic = schematic; + } + + public double InputScale { get; set; } = 1; + + public double OutputScale { get; set; } = 1; + + public void Start(double sampleRate, int oversample, int iterations) + { + lock (sync) + { + simulation = AudioSimulationFactory.Create(schematic.Build(), sampleRate, oversample, iterations); + } + } + + public void Stop() + { + lock (sync) + { + simulation = null; + } + } + + public double[] Process(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + Simulation? current; + lock (sync) + current = simulation; + + if (current == null) + { + foreach (Audio.SampleBuffer buffer in output) + buffer.Clear(); + return Array.Empty(); + } + + double[] inputSamples = new double[count]; + if (input.Length > 0) + Array.Copy(input[0].Samples, inputSamples, count); + else + for (int sample = 0; sample < count; sample++) + inputSamples[sample] = 0.25 * Math.Sin(2 * Math.PI * 440 * (current.Time + sample / rate)); + for (int sample = 0; sample < count; sample++) + inputSamples[sample] *= InputScale; + + double[] outputSamples = new double[count]; + lock (sync) + current.Run(count, new[] { inputSamples }, new[] { outputSamples }); + for (int sample = 0; sample < count; sample++) + outputSamples[sample] *= OutputScale; + foreach (Audio.SampleBuffer buffer in output) + Array.Copy(outputSamples, buffer.Samples, count); + return outputSamples; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj index 932fac5f..73382196 100644 --- a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj +++ b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + true LiveSPICE.Avalonia LiveSPICE.Avalonia @@ -16,6 +17,7 @@ + diff --git a/LiveSPICE.Avalonia/PluginEditorWindow.cs b/LiveSPICE.Avalonia/PluginEditorWindow.cs new file mode 100644 index 00000000..fbb1b010 --- /dev/null +++ b/LiveSPICE.Avalonia/PluginEditorWindow.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Circuit; +using LiveSPICE.PluginCore; + +namespace LiveSPICE.Avalonia; + +public sealed class PluginEditorWindow : Window +{ + private readonly SimulationProcessor processor; + private readonly SchematicCanvas schematicCanvas; + private readonly StackPanel controlsPanel; + private readonly ComboBox oversampleBox; + private readonly ComboBox iterationsBox; + private readonly TextBlock loadedText; + private string? loadedPath; + + public PluginEditorWindow() : this(new SimulationProcessor()) + { + } + + public PluginEditorWindow(SimulationProcessor processor) + { + this.processor = processor; + Title = "LiveSPICE Plugin"; + Width = 700; + Height = 420; + MinWidth = 520; + MinHeight = 320; + + schematicCanvas = new SchematicCanvas { IsHitTestVisible = false, Opacity = 0.45, Margin = new Thickness(0) }; + controlsPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center }; + oversampleBox = CreateOptionBox(new[] { 1, 2, 4, 8 }, processor.Oversample); + iterationsBox = CreateOptionBox(new[] { 1, 2, 4, 8, 16, 32, 64 }, processor.Iterations); + loadedText = new TextBlock { Text = "Load Schematic", TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; + + Content = BuildLayout(); + RefreshFromProcessor(); + } + + public void LoadSchematic(string path) + { + processor.LoadSchematic(path); + loadedPath = path; + RefreshFromProcessor(); + } + + private Control BuildLayout() + { + Grid root = new Grid + { + Background = new SolidColorBrush(Color.FromRgb(72, 72, 72)), + RowDefinitions = new RowDefinitions("Auto,Auto,Auto,*"), + }; + + root.Children.Add(schematicCanvas); + Grid.SetRowSpan(schematicCanvas, 4); + + Button loadButton = new Button { Content = loadedText, HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(14, 12, 14, 4) }; + loadButton.Click += LoadButton_Click; + root.Children.Add(loadButton); + + StackPanel commandRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(14, 4) }; + Button reloadButton = new Button { Content = "Reload" }; + reloadButton.Click += ReloadButton_Click; + Button viewButton = new Button { Content = "View" }; + viewButton.Click += ViewButton_Click; + Button aboutButton = new Button { Content = "About" }; + aboutButton.Click += AboutButton_Click; + commandRow.Children.Add(reloadButton); + commandRow.Children.Add(viewButton); + commandRow.Children.Add(aboutButton); + root.Children.Add(commandRow); + Grid.SetRow(commandRow, 1); + + StackPanel settingsRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(14, 4) }; + settingsRow.Children.Add(new TextBlock { Text = "Oversample", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }); + settingsRow.Children.Add(oversampleBox); + settingsRow.Children.Add(new TextBlock { Text = "Iterations", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }); + settingsRow.Children.Add(iterationsBox); + root.Children.Add(settingsRow); + Grid.SetRow(settingsRow, 2); + + ScrollViewer controlsScroll = new ScrollViewer + { + Content = controlsPanel, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + Margin = new Thickness(14, 8, 14, 14), + }; + root.Children.Add(controlsScroll); + Grid.SetRow(controlsScroll, 3); + + return root; + } + + private static ComboBox CreateOptionBox(int[] values, int selected) + { + ComboBox comboBox = new ComboBox { Width = 76, ItemsSource = values.Cast().ToArray() }; + comboBox.SelectedItem = selected; + return comboBox; + } + + private async void LoadButton_Click(object? sender, RoutedEventArgs e) + { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("Circuit Schematics") { Patterns = new[] { "*.schx" } }, + FilePickerFileTypes.All, + }, + Title = "Open schematic", + }); + + string? path = files.FirstOrDefault()?.Path.LocalPath; + if (!string.IsNullOrEmpty(path)) + LoadSchematic(path); + } + + private void ReloadButton_Click(object? sender, RoutedEventArgs e) + { + if (!string.IsNullOrEmpty(loadedPath) && File.Exists(loadedPath)) + LoadSchematic(loadedPath); + } + + private void ViewButton_Click(object? sender, RoutedEventArgs e) + { + if (processor.Schematic == null) + return; + + Window window = new Window + { + Title = processor.SchematicName, + Width = 1000, + Height = 720, + Content = new SchematicCanvas { Document = new SchematicDocument(processor.Schematic, loadedPath) }, + }; + window.Show(); + } + + private void AboutButton_Click(object? sender, RoutedEventArgs e) + { + Window about = new Window + { + Title = "About LiveSPICE Plugin", + Width = 320, + Height = 160, + Content = new TextBlock + { + Text = "LiveSPICE Linux Plugin\nlivespice.org", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center, + }, + }; + about.ShowDialog(this); + } + + private void RefreshFromProcessor() + { + loadedText.Text = processor.Schematic == null ? "Load Schematic" : processor.SchematicName; + schematicCanvas.Document = new SchematicDocument(processor.Schematic ?? new Schematic(), loadedPath); + oversampleBox.SelectionChanged -= OversampleBox_SelectionChanged; + iterationsBox.SelectionChanged -= IterationsBox_SelectionChanged; + oversampleBox.SelectedItem = processor.Oversample; + iterationsBox.SelectedItem = processor.Iterations; + oversampleBox.SelectionChanged += OversampleBox_SelectionChanged; + iterationsBox.SelectionChanged += IterationsBox_SelectionChanged; + RebuildControls(); + } + + private void RebuildControls() + { + controlsPanel.Children.Clear(); + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + controlsPanel.Children.Add(CreateControl(wrapper)); + } + + private Control CreateControl(IComponentWrapper wrapper) + { + StackPanel panel = new StackPanel { Width = 96, Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center }; + panel.Children.Add(new TextBlock + { + Text = wrapper.Name, + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + + switch (wrapper) + { + case PotWrapper potWrapper: + Slider slider = new Slider { Minimum = 0, Maximum = 1, Value = potWrapper.PotValue, Width = 82 }; + slider.PropertyChanged += (_, args) => + { + if (args.Property == RangeBase.ValueProperty) + potWrapper.PotValue = slider.Value; + }; + panel.Children.Add(slider); + break; + case DoubleThrowWrapper doubleThrowWrapper: + ToggleButton toggle = new ToggleButton { IsChecked = doubleThrowWrapper.Engaged, HorizontalAlignment = HorizontalAlignment.Center }; + toggle.IsCheckedChanged += (_, _) => doubleThrowWrapper.Engaged = toggle.IsChecked == true; + panel.Children.Add(toggle); + break; + case MultiThrowWrapper multiThrowWrapper: + ComboBox comboBox = CreateOptionBox(new[] { 0, 1, 2 }, multiThrowWrapper.Position); + comboBox.SelectionChanged += (_, _) => + { + if (comboBox.SelectedItem is int position) + multiThrowWrapper.Position = position; + }; + panel.Children.Add(comboBox); + break; + } + + return panel; + } + + private void OversampleBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (oversampleBox.SelectedItem is int value) + processor.Oversample = value; + } + + private void IterationsBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (iterationsBox.SelectedItem is int value) + processor.Iterations = value; + } +} diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index 7ff7c607..e4854c10 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -23,14 +23,16 @@ public sealed class WaveformWindow : Window private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; private readonly TextBlock audioConfig = new TextBlock { TextWrapping = TextWrapping.Wrap }; private readonly TextBox log = new TextBox { IsReadOnly = true, AcceptsReturn = true, MinHeight = 100, TextWrapping = TextWrapping.Wrap }; - private readonly object simulationLock = new object(); + private readonly LiveAudioProcessor liveProcessor; + private double liveInputScale = 1; + private double liveOutputScale = 1; private Audio.Stream? liveStream; - private Simulation? liveSimulation; public WaveformWindow(Schematic schematic, AppSettings settings) { this.schematic = schematic; this.settings = settings; + liveProcessor = new LiveAudioProcessor(schematic); Title = "Simulation Scope"; Width = 1000; Height = 700; @@ -69,6 +71,8 @@ private Control BuildContent() Grid main = new Grid { ColumnDefinitions = new ColumnDefinitions("220,*"), RowDefinitions = new RowDefinitions("*,150") }; StackPanel audio = new StackPanel { Spacing = 10, Margin = new global::Avalonia.Thickness(10) }; + inputGain.PropertyChanged += (_, e) => { if (e.Property == Slider.ValueProperty) liveInputScale = DbToLinear(inputGain.Value); }; + outputGain.PropertyChanged += (_, e) => { if (e.Property == Slider.ValueProperty) liveOutputScale = DbToLinear(outputGain.Value); }; audio.Children.Add(Header("Audio")); audio.Children.Add(audioConfig); audio.Children.Add(Button("Configure", (_, _) => @@ -163,16 +167,12 @@ private void ToggleLive() int oversampleValue = ParseInt(oversample.Text, 8, 1, 64); int iterationValue = ParseInt(iterations.Text, 8, 1, 64); - lock (simulationLock) - { - liveSimulation = AudioSimulationFactory.Create(schematic.Build(), 48000, oversampleValue, iterationValue); - } + liveInputScale = DbToLinear(inputGain.Value); + liveOutputScale = DbToLinear(outputGain.Value); + StartLiveSimulation(48000, oversampleValue, iterationValue); liveStream = device.Open(ProcessLiveSamples, input, output); - lock (simulationLock) - { - liveSimulation = AudioSimulationFactory.Create(schematic.Build(), liveStream.SampleRate, oversampleValue, iterationValue); - } + StartLiveSimulation(liveStream.SampleRate, oversampleValue, iterationValue); log.Text = $"Live stream started\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {device.Name}\nSample rate: {liveStream.SampleRate:0}"; } catch (Exception ex) @@ -188,47 +188,19 @@ private void StopLive() liveStream = null; if (stream != null) stream.Stop(); - lock (simulationLock) - { - liveSimulation = null; - } + liveProcessor.Stop(); } private void ProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) { try { - Simulation? simulation; - lock (simulationLock) - simulation = liveSimulation; - - if (simulation == null) - { - foreach (Audio.SampleBuffer buffer in output) - buffer.Clear(); - return; - } + liveProcessor.InputScale = liveInputScale; + liveProcessor.OutputScale = liveOutputScale; + double[] outputSamples = liveProcessor.Process(count, input, output, rate); - double inputScale = DbToLinear(inputGain.Value); - double outputScale = DbToLinear(outputGain.Value); - double[] inputSamples = new double[count]; - if (input.Length > 0) - Array.Copy(input[0].Samples, inputSamples, count); - else - for (int sample = 0; sample < count; sample++) - inputSamples[sample] = 0.25 * Math.Sin(2 * Math.PI * 440 * (simulation.Time + sample / rate)); - for (int sample = 0; sample < count; sample++) - inputSamples[sample] *= inputScale; - - double[] outputSamples = new double[count]; - lock (simulationLock) - simulation.Run(count, new[] { inputSamples }, new[] { outputSamples }); - for (int sample = 0; sample < count; sample++) - outputSamples[sample] *= outputScale; - foreach (Audio.SampleBuffer buffer in output) - Array.Copy(outputSamples, buffer.Samples, count); - - Dispatcher.UIThread.Post(() => waveform.SetSamples(outputSamples, (int)rate)); + if (outputSamples.Length > 0) + Dispatcher.UIThread.Post(() => waveform.SetSamples(outputSamples, (int)rate)); } catch (Exception ex) { @@ -238,6 +210,20 @@ private void ProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audio.Sam } } + internal void StartLiveSimulation(double sampleRate, int oversampleValue = 8, int iterationValue = 8) + { + liveInputScale = DbToLinear(inputGain.Value); + liveOutputScale = DbToLinear(outputGain.Value); + liveProcessor.InputScale = liveInputScale; + liveProcessor.OutputScale = liveOutputScale; + liveProcessor.Start(sampleRate, oversampleValue, iterationValue); + } + + internal void TestProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + ProcessLiveSamples(count, input, output, rate); + } + private Audio.Device SelectedDevice() { Audio.Driver driver = Audio.Driver.Drivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? Audio.Driver.Drivers.First(); diff --git a/LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj b/LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj new file mode 100644 index 00000000..331ff4fe --- /dev/null +++ b/LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj @@ -0,0 +1,13 @@ + + + net8.0 + enable + enable + LiveSPICE.PluginCore + + + + + + + diff --git a/LiveSPICE.PluginCore/PluginProgramParameters.cs b/LiveSPICE.PluginCore/PluginProgramParameters.cs new file mode 100644 index 00000000..954ac268 --- /dev/null +++ b/LiveSPICE.PluginCore/PluginProgramParameters.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; + +namespace LiveSPICE.PluginCore; + +public class PluginProgramParameters +{ + public string? SchematicPath { get; set; } + + public int OverSample { get; set; } = 2; + + public int Iterations { get; set; } = 8; + + public List ControlParameters { get; set; } = new List(); + + public static PluginProgramParameters FromProcessor(SimulationProcessor processor) + { + PluginProgramParameters parameters = new PluginProgramParameters + { + SchematicPath = processor.SchematicPath, + OverSample = processor.Oversample, + Iterations = processor.Iterations, + }; + + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + { + switch (wrapper) + { + case PotWrapper potWrapper: + parameters.ControlParameters.Add(new PluginProgramControlParameter { Name = wrapper.Name, Value = potWrapper.PotValue }); + break; + case DoubleThrowWrapper doubleThrowWrapper: + parameters.ControlParameters.Add(new PluginProgramControlParameter { Name = wrapper.Name, Value = doubleThrowWrapper.Engaged ? 1 : 0 }); + break; + case MultiThrowWrapper multiThrowWrapper: + parameters.ControlParameters.Add(new PluginProgramControlParameter { Name = wrapper.Name, Value = multiThrowWrapper.Position }); + break; + } + } + + return parameters; + } + + public void ApplyTo(SimulationProcessor processor) + { + processor.Oversample = OverSample; + processor.Iterations = Iterations; + + foreach (PluginProgramControlParameter controlParameter in ControlParameters) + { + IComponentWrapper? wrapper = processor.InteractiveComponents.SingleOrDefault(i => i.Name == controlParameter.Name); + if (wrapper == null) + continue; + + switch (wrapper) + { + case PotWrapper potWrapper: + potWrapper.PotValue = controlParameter.Value; + break; + case DoubleThrowWrapper doubleThrowWrapper: + doubleThrowWrapper.Engaged = controlParameter.Value == 1; + break; + case MultiThrowWrapper multiThrowWrapper: + multiThrowWrapper.Position = (int)controlParameter.Value; + break; + } + } + } +} + +public class PluginProgramControlParameter +{ + public string? Name { get; set; } + + public double Value { get; set; } +} diff --git a/LiveSPICE.PluginCore/SimulationProcessor.cs b/LiveSPICE.PluginCore/SimulationProcessor.cs new file mode 100644 index 00000000..5e598503 --- /dev/null +++ b/LiveSPICE.PluginCore/SimulationProcessor.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using Circuit; +using Util; +using ButtonWrapper = LiveSPICE.PluginCore.ComponentWrapper; + +namespace LiveSPICE.PluginCore; + +public class SimulationProcessor +{ + private double sampleRate; + private int oversample = 2; + private int iterations = 8; + private Circuit.Circuit? circuit; + private Simulation? simulation; + private bool needUpdate; + private bool needRebuild; + private int updateSamplesElapsed; + private int delayUpdateSamples; + private Exception? simulationUpdateException; + private int clock = -1; + private int update; + private readonly TaskScheduler scheduler = new RedundantTaskScheduler(1); + private readonly object sync = new object(); + + public SimulationProcessor() + { + InteractiveComponents = new ObservableCollection(); + SampleRate = 44100; + } + + public ObservableCollection InteractiveComponents { get; } + + public Schematic? Schematic { get; private set; } + + public string SchematicPath { get; private set; } = string.Empty; + + public string SchematicName => System.IO.Path.GetFileNameWithoutExtension(SchematicPath); + + public double SampleRate + { + get => sampleRate; + set + { + if (sampleRate == value) + return; + + sampleRate = value; + needRebuild = true; + delayUpdateSamples = (int)(sampleRate * .1); + } + } + + public int Oversample + { + get => oversample; + set + { + if (oversample == value) + return; + + oversample = value; + needRebuild = true; + } + } + + public int Iterations + { + get => iterations; + set + { + if (iterations == value) + return; + + iterations = value; + needRebuild = true; + } + } + + public void LoadSchematic(string path) + { + Schematic newSchematic = Circuit.Schematic.Load(path); + Circuit.Circuit newCircuit = newSchematic.Build(); + SetCircuit(newCircuit); + Schematic = newSchematic; + SchematicPath = path; + } + + public void ClearSchematic() + { + Schematic = null; + SchematicPath = string.Empty; + circuit = null; + InteractiveComponents.Clear(); + } + + public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int numSamples) + { + if (simulationUpdateException != null) + { + Exception toThrow = simulationUpdateException; + simulationUpdateException = null; + throw toThrow; + } + + if (simulation == null && needRebuild && circuit != null) + { + UpdateSimulation(needRebuild); + needRebuild = false; + } + + if (circuit == null || simulation == null) + { + audioInputs[0].CopyTo(audioOutputs[0], 0); + return; + } + + lock (sync) + { + foreach (IComponentWrapper component in InteractiveComponents) + { + if (component.NeedUpdate) + { + needUpdate = true; + component.NeedUpdate = false; + updateSamplesElapsed = 0; + } + + if (component.NeedRebuild) + { + needRebuild = true; + component.NeedRebuild = false; + } + } + + if (needUpdate || needRebuild) + { + if (needRebuild || updateSamplesElapsed > delayUpdateSamples) + { + UpdateSimulation(needRebuild); + needRebuild = false; + needUpdate = false; + } + else + { + updateSamplesElapsed += numSamples; + } + } + + simulation.Run(numSamples, audioInputs, audioOutputs); + } + } + + private void SetCircuit(Circuit.Circuit newCircuit) + { + circuit = newCircuit; + InteractiveComponents.Clear(); + + Dictionary buttonGroups = new Dictionary(); + Dictionary potGroups = new Dictionary(); + + foreach (Circuit.Component component in circuit.Components) + { + if (component is IPotControl pot) + AddPotControl(component, pot, potGroups); + else if (component is IButtonControl button) + AddButtonControl(component, button, buttonGroups); + } + + needRebuild = true; + } + + private void AddPotControl(Circuit.Component component, IPotControl pot, Dictionary potGroups) + { + if (string.IsNullOrEmpty(pot.Group)) + { + InteractiveComponents.Add(new PotWrapper(pot, component.Name)); + } + else if (potGroups.TryGetValue(pot.Group, out PotWrapper? wrapper)) + { + wrapper.AddSection(pot); + } + else + { + wrapper = new PotWrapper(pot, pot.Group); + potGroups.Add(pot.Group, wrapper); + InteractiveComponents.Add(wrapper); + } + } + + private void AddButtonControl(Circuit.Component component, IButtonControl button, Dictionary buttonGroups) + { + ButtonWrapper wrapper; + if (string.IsNullOrEmpty(button.Group)) + { + wrapper = button.NumPositions == 2 + ? new DoubleThrowWrapper(button, component.Name) + : new MultiThrowWrapper(button, component.Name); + InteractiveComponents.Add(wrapper); + } + else if (buttonGroups.ContainsKey(button.Group)) + { + wrapper = buttonGroups[button.Group]; + wrapper.AddSection(button); + } + else + { + wrapper = button.NumPositions == 2 + ? new DoubleThrowWrapper(button, button.Group) + : new MultiThrowWrapper(button, component.Name); + buttonGroups[button.Group] = wrapper; + InteractiveComponents.Add(wrapper); + } + } + + private void UpdateSimulation(bool rebuild) + { + int id = Interlocked.Increment(ref update); + new Task(() => + { + try + { + if (circuit == null) + return; + + TransientSolution solution = AudioSimulationFactory.Solve(circuit, sampleRate, oversample); + lock (sync) + { + if (id <= clock) + return; + + if (rebuild || simulation == null) + { + simulation = AudioSimulationFactory.Create(circuit, solution, oversample, iterations); + } + else + { + simulation.Solution = solution; + } + + clock = id; + } + } + catch (Exception ex) + { + simulationUpdateException = ex; + } + }).Start(scheduler); + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs b/LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs new file mode 100644 index 00000000..751058ff --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace LiveSPICE.PluginCore; + +public abstract class ComponentWrapper : IComponentWrapper +{ + protected ComponentWrapper(T component, string name) + { + Name = name; + AddSection(component); + } + + public string Name { get; } + + public bool NeedUpdate { get; set; } + + public bool NeedRebuild { get; set; } + + protected List Sections { get; } = new List(); + + public void AddSection(T section) + { + Sections.Add(section); + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs b/LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs new file mode 100644 index 00000000..8cee9b88 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs @@ -0,0 +1,28 @@ +using Circuit; + +namespace LiveSPICE.PluginCore; + +public class DoubleThrowWrapper : ComponentWrapper +{ + private bool engaged; + + public DoubleThrowWrapper(IButtonControl button, string name) : base(button, name) + { + } + + public bool Engaged + { + get => engaged; + set + { + if (value == engaged) + return; + + engaged = value; + foreach (IButtonControl button in Sections) + button.Click(); + + NeedRebuild = true; + } + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs b/LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs new file mode 100644 index 00000000..7f19dda4 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs @@ -0,0 +1,10 @@ +namespace LiveSPICE.PluginCore; + +public interface IComponentWrapper +{ + string Name { get; } + + bool NeedRebuild { get; set; } + + bool NeedUpdate { get; set; } +} diff --git a/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs b/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs new file mode 100644 index 00000000..7fd975cd --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs @@ -0,0 +1,25 @@ +using Circuit; + +namespace LiveSPICE.PluginCore; + +public class MultiThrowWrapper : ComponentWrapper +{ + public MultiThrowWrapper(IButtonControl button, string name) : base(button, name) + { + } + + public int Position + { + get => Sections[0].Position; + set + { + if (value == Sections[0].Position) + return; + + foreach (IButtonControl section in Sections) + section.Position = value; + + NeedRebuild = true; + } + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/PotWrapper.cs b/LiveSPICE.PluginCore/Wrappers/PotWrapper.cs new file mode 100644 index 00000000..0a2766f3 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/PotWrapper.cs @@ -0,0 +1,25 @@ +using Circuit; + +namespace LiveSPICE.PluginCore; + +public class PotWrapper : ComponentWrapper +{ + public PotWrapper(IPotControl pot, string name) : base(pot, name) + { + } + + public double PotValue + { + get => Sections[0].PotValue; + set + { + if (Sections[0].PotValue == value) + return; + + foreach (IPotControl section in Sections) + section.PotValue = value; + + NeedUpdate = true; + } + } +} diff --git a/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj new file mode 100644 index 00000000..912c01d5 --- /dev/null +++ b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj @@ -0,0 +1,16 @@ + + + net8.0 + Library + LiveSPICE.PluginLinux + LiveSPICE Linux VST + enable + enable + + + + + + + + diff --git a/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs new file mode 100644 index 00000000..c27a99df --- /dev/null +++ b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Serialization; +using AudioPlugSharp; +using LiveSPICE.PluginCore; + +namespace LiveSPICE.PluginLinux; + +public class LiveSPICELinuxPlugin : AudioPluginBase +{ + private AudioIOPortManaged? monoInput; + private AudioIOPortManaged? monoOutput; + private bool haveSimulationError; + + public LiveSPICELinuxPlugin() + { + Company = string.Empty; + Website = "livespice.org"; + Contact = string.Empty; + PluginName = "LiveSPICE Linux"; + PluginCategory = "Fx"; + PluginVersion = "1.1.0"; + PluginID = 0xDC8558DC41A44872; + HasUserInterface = false; + EditorWidth = 700; + EditorHeight = 420; + SimulationProcessor = new SimulationProcessor(); + } + + public SimulationProcessor SimulationProcessor { get; } + + public string SchematicPath => SimulationProcessor.SchematicPath; + + public override void Initialize() + { + base.Initialize(); + InputPorts = new AudioIOPort[] { monoInput = new AudioIOPortManaged("Mono Input", EAudioChannelConfiguration.Mono) }; + OutputPorts = new AudioIOPort[] { monoOutput = new AudioIOPortManaged("Mono Output", EAudioChannelConfiguration.Mono) }; + } + + public override void InitializeProcessing() + { + base.InitializeProcessing(); + SimulationProcessor.SampleRate = Host.SampleRate; + } + + public void LoadSchematic(string path) + { + haveSimulationError = false; + SimulationProcessor.LoadSchematic(path); + } + + public override byte[] SaveState() + { + PluginProgramParameters parameters = PluginProgramParameters.FromProcessor(SimulationProcessor); + XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); + using MemoryStream memoryStream = new MemoryStream(); + serializer.Serialize(memoryStream, parameters); + return memoryStream.ToArray(); + } + + public override void RestoreState(byte[] stateData) + { + XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); + try + { + using MemoryStream memoryStream = new MemoryStream(stateData); + if (serializer.Deserialize(memoryStream) is not PluginProgramParameters parameters) + return; + + if (string.IsNullOrEmpty(parameters.SchematicPath)) + { + haveSimulationError = false; + SimulationProcessor.ClearSchematic(); + } + else + { + LoadSchematic(parameters.SchematicPath); + } + + parameters.ApplyTo(SimulationProcessor); + } + catch (Exception ex) + { + Logger.Log("Load state failed: " + ex.Message); + } + } + + public override void Process() + { + base.Process(); + + if (monoInput == null || monoOutput == null) + return; + + if (haveSimulationError) + { + monoInput.PassThroughTo(monoOutput); + return; + } + + double[][] inputBuffers = monoInput.GetAudioBuffers(); + double[][] outputBuffers = monoOutput.GetAudioBuffers(); + + try + { + SimulationProcessor.RunSimulation(inputBuffers, outputBuffers, inputBuffers[0].Length); + } + catch (Exception ex) + { + haveSimulationError = true; + Logger.Log("Error running circuit simulation: " + ex.Message); + monoInput.PassThroughTo(monoOutput); + } + } +} diff --git a/LiveSPICE.sln b/LiveSPICE.sln index 450a1f72..18f7f50c 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -42,6 +42,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia", "LiveS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia.Tests", "LiveSPICE.Avalonia.Tests\LiveSPICE.Avalonia.Tests.csproj", "{632F80C2-2019-4EF9-902B-ADE2977CC281}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginCore", "LiveSPICE.PluginCore\LiveSPICE.PluginCore.csproj", "{9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginLinux", "LiveSPICE.PluginLinux\LiveSPICE.PluginLinux.csproj", "{0115FEA0-A941-4C74-B020-A8329BB91CC3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -112,6 +116,14 @@ Global {632F80C2-2019-4EF9-902B-ADE2977CC281}.Debug|Any CPU.Build.0 = Debug|Any CPU {632F80C2-2019-4EF9-902B-ADE2977CC281}.Release|Any CPU.ActiveCfg = Release|Any CPU {632F80C2-2019-4EF9-902B-ADE2977CC281}.Release|Any CPU.Build.0 = Release|Any CPU + {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Release|Any CPU.Build.0 = Release|Any CPU + {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LiveSPICEVst/EditorView.xaml.cs b/LiveSPICEVst/EditorView.xaml.cs index d68599e4..40fc8521 100644 --- a/LiveSPICEVst/EditorView.xaml.cs +++ b/LiveSPICEVst/EditorView.xaml.cs @@ -3,6 +3,7 @@ using System.IO; using System.Windows; using System.Windows.Controls; +using LiveSPICE.PluginCore; namespace LiveSPICEVst { diff --git a/LiveSPICEVst/LiveSPICEPlugin.cs b/LiveSPICEVst/LiveSPICEPlugin.cs index 913c14f3..0ae7a0a8 100644 --- a/LiveSPICEVst/LiveSPICEPlugin.cs +++ b/LiveSPICEVst/LiveSPICEPlugin.cs @@ -10,6 +10,7 @@ using System.Xml.Serialization; using AudioPlugSharp; using AudioPlugSharpWPF; +using LiveSPICE.PluginCore; namespace LiveSPICEVst { @@ -79,32 +80,8 @@ public override byte[] SaveState() { byte[] stateData = null; - VstProgramParameters programParameters = new VstProgramParameters - { - SchematicPath = SimulationProcessor.SchematicPath, - OverSample = SimulationProcessor.Oversample, - Iterations = SimulationProcessor.Iterations - }; - - foreach (var wrapper in SimulationProcessor.InteractiveComponents) - { - switch (wrapper) - { - case PotWrapper potWrapper: - programParameters.ControlParameters.Add(new VSTProgramControlParameter { Name = wrapper.Name, Value = potWrapper.PotValue }); - break; - - case DoubleThrowWrapper doubleThrowWrapper: - programParameters.ControlParameters.Add(new VSTProgramControlParameter { Name = wrapper.Name, Value = doubleThrowWrapper.Engaged ? 1 : 0 }); - break; - - case MultiThrowWrapper multiThrowWrapper: - programParameters.ControlParameters.Add(new VSTProgramControlParameter { Name = wrapper.Name, Value = multiThrowWrapper.Position }); - break; - } - } - - XmlSerializer serializer = new XmlSerializer(typeof(VstProgramParameters)); + PluginProgramParameters programParameters = PluginProgramParameters.FromProcessor(SimulationProcessor); + XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); using (MemoryStream memoryStream = new MemoryStream()) { @@ -122,13 +99,13 @@ public override byte[] SaveState() /// Byte array of data to restore public override void RestoreState(byte[] stateData) { - XmlSerializer serializer = new XmlSerializer(typeof(VstProgramParameters)); + XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); try { using (MemoryStream memoryStream = new MemoryStream(stateData)) { - VstProgramParameters programParameters = serializer.Deserialize(memoryStream) as VstProgramParameters; + PluginProgramParameters programParameters = serializer.Deserialize(memoryStream) as PluginProgramParameters; if (string.IsNullOrEmpty(programParameters.SchematicPath)) { @@ -141,31 +118,7 @@ public override void RestoreState(byte[] stateData) LoadSchematic(programParameters.SchematicPath); } - SimulationProcessor.Oversample = programParameters.OverSample; - SimulationProcessor.Iterations = programParameters.Iterations; - - foreach (VSTProgramControlParameter controlParameter in programParameters.ControlParameters) - { - var wrapper = SimulationProcessor.InteractiveComponents.Where(i => i.Name == controlParameter.Name).SingleOrDefault(); - - if (wrapper != null) - { - switch (wrapper) - { - case PotWrapper potWrapper: - potWrapper.PotValue = controlParameter.Value; - break; - - case DoubleThrowWrapper doubleThrowWrapper: - doubleThrowWrapper.Engaged = (controlParameter.Value == 1); - break; - - case MultiThrowWrapper multiThrowWrapper: - multiThrowWrapper.Position = (int)controlParameter.Value; - break; - } - } - } + programParameters.ApplyTo(SimulationProcessor); if (EditorView != null) EditorView.UpdateSchematic(); diff --git a/LiveSPICEVst/LiveSPICEVst.csproj b/LiveSPICEVst/LiveSPICEVst.csproj index ed3b0421..1e6cc75c 100644 --- a/LiveSPICEVst/LiveSPICEVst.csproj +++ b/LiveSPICEVst/LiveSPICEVst.csproj @@ -20,6 +20,7 @@ + @@ -29,4 +30,8 @@ + + + + \ No newline at end of file diff --git a/LiveSPICEVst/SimulationInterface.xaml b/LiveSPICEVst/SimulationInterface.xaml index 337ce28d..6ff7863e 100644 --- a/LiveSPICEVst/SimulationInterface.xaml +++ b/LiveSPICEVst/SimulationInterface.xaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:LiveSPICEVst" + xmlns:core="clr-namespace:LiveSPICE.PluginCore;assembly=LiveSPICE.PluginCore" xmlns:AudioPlugSharpWPF="clr-namespace:AudioPlugSharpWPF;assembly=AudioPlugSharpWPF" xmlns:sys="clr-namespace:System;assembly=mscorlib" mc:Ignorable="d" @@ -51,7 +52,7 @@ - + @@ -62,7 +63,7 @@ - + diff --git a/LiveSPICEVst/SimulationInterface.xaml.cs b/LiveSPICEVst/SimulationInterface.xaml.cs index bdfe9afa..9dadb343 100644 --- a/LiveSPICEVst/SimulationInterface.xaml.cs +++ b/LiveSPICEVst/SimulationInterface.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Windows.Controls; using System.Windows.Data; +using LiveSPICE.PluginCore; namespace LiveSPICEVst { From 016da0b33f9a2e8a89f4bdb606436a65de580e3f Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 21:09:38 +0200 Subject: [PATCH 15/42] Improve Avalonia plugin editor parity --- LiveSPICE.Avalonia.Tests/PluginPortTests.cs | 14 +++ LiveSPICE.Avalonia/PluginEditorWindow.cs | 126 ++++++++++++++++++++ LiveSPICE.Avalonia/SchematicCanvas.cs | 5 + 3 files changed, 145 insertions(+) diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs index 45d3a081..10c32d07 100644 --- a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -1,4 +1,6 @@ using AudioPlugSharp; +using System.IO; +using LiveSPICE.Avalonia; using LiveSPICE.PluginCore; using LiveSPICE.PluginLinux; using Xunit; @@ -52,4 +54,16 @@ public void LinuxPluginStateRoundTripsProcessorSettings() Assert.Equal(8, restored.SimulationProcessor.Oversample); Assert.Equal(32, restored.SimulationProcessor.Iterations); } + + [Fact] + public void PluginEditorCreatesOverlayControlsForInteractiveSchematic() + { + SimulationProcessor processor = new SimulationProcessor(); + string schematicPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Tests/Circuits/59 Bassman Preamp.schx")); + processor.LoadSchematic(schematicPath); + + Assert.NotEmpty(processor.InteractiveComponents); + Assert.All(processor.InteractiveComponents, wrapper => + Assert.Contains(processor.Schematic!.Symbols, symbol => PluginEditorWindow.WrapperMatchesSymbol(wrapper, symbol))); + } } diff --git a/LiveSPICE.Avalonia/PluginEditorWindow.cs b/LiveSPICE.Avalonia/PluginEditorWindow.cs index fbb1b010..78c4dcd3 100644 --- a/LiveSPICE.Avalonia/PluginEditorWindow.cs +++ b/LiveSPICE.Avalonia/PluginEditorWindow.cs @@ -9,8 +9,10 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.Threading; using Circuit; using LiveSPICE.PluginCore; +using APoint = Avalonia.Point; namespace LiveSPICE.Avalonia; @@ -18,10 +20,13 @@ public sealed class PluginEditorWindow : Window { private readonly SimulationProcessor processor; private readonly SchematicCanvas schematicCanvas; + private readonly Canvas overlayCanvas; private readonly StackPanel controlsPanel; private readonly ComboBox oversampleBox; private readonly ComboBox iterationsBox; + private readonly CheckBox autoReloadBox; private readonly TextBlock loadedText; + private FileSystemWatcher? fileWatcher; private string? loadedPath; public PluginEditorWindow() : this(new SimulationProcessor()) @@ -38,9 +43,13 @@ public PluginEditorWindow(SimulationProcessor processor) MinHeight = 320; schematicCanvas = new SchematicCanvas { IsHitTestVisible = false, Opacity = 0.45, Margin = new Thickness(0) }; + schematicCanvas.LayoutUpdated += (_, _) => PositionOverlayControls(); + overlayCanvas = new Canvas { IsHitTestVisible = true, Margin = new Thickness(0) }; controlsPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center }; oversampleBox = CreateOptionBox(new[] { 1, 2, 4, 8 }, processor.Oversample); iterationsBox = CreateOptionBox(new[] { 1, 2, 4, 8, 16, 32, 64 }, processor.Iterations); + autoReloadBox = new CheckBox { Content = "Auto Reload", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }; + autoReloadBox.IsCheckedChanged += (_, _) => ConfigureAutoReload(); loadedText = new TextBlock { Text = "Load Schematic", TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; Content = BuildLayout(); @@ -54,6 +63,13 @@ public void LoadSchematic(string path) RefreshFromProcessor(); } + internal int TestOverlayControlCount => overlayCanvas.Children.Count; + + internal APoint TestSchematicPointFor(IComponentWrapper wrapper) + { + return SchematicPointFor(wrapper); + } + private Control BuildLayout() { Grid root = new Grid @@ -64,6 +80,8 @@ private Control BuildLayout() root.Children.Add(schematicCanvas); Grid.SetRowSpan(schematicCanvas, 4); + root.Children.Add(overlayCanvas); + Grid.SetRowSpan(overlayCanvas, 4); Button loadButton = new Button { Content = loadedText, HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(14, 12, 14, 4) }; loadButton.Click += LoadButton_Click; @@ -87,6 +105,7 @@ private Control BuildLayout() settingsRow.Children.Add(oversampleBox); settingsRow.Children.Add(new TextBlock { Text = "Iterations", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }); settingsRow.Children.Add(iterationsBox); + settingsRow.Children.Add(autoReloadBox); root.Children.Add(settingsRow); Grid.SetRow(settingsRow, 2); @@ -125,7 +144,10 @@ private async void LoadButton_Click(object? sender, RoutedEventArgs e) string? path = files.FirstOrDefault()?.Path.LocalPath; if (!string.IsNullOrEmpty(path)) + { LoadSchematic(path); + ConfigureAutoReload(); + } } private void ReloadButton_Click(object? sender, RoutedEventArgs e) @@ -178,6 +200,7 @@ private void RefreshFromProcessor() oversampleBox.SelectionChanged += OversampleBox_SelectionChanged; iterationsBox.SelectionChanged += IterationsBox_SelectionChanged; RebuildControls(); + RebuildOverlayControls(); } private void RebuildControls() @@ -229,6 +252,109 @@ private Control CreateControl(IComponentWrapper wrapper) return panel; } + private void RebuildOverlayControls() + { + overlayCanvas.Children.Clear(); + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + { + Control control = CreateOverlayControl(wrapper); + control.Tag = wrapper; + overlayCanvas.Children.Add(control); + } + + PositionOverlayControls(); + } + + private Control CreateOverlayControl(IComponentWrapper wrapper) + { + Border shell = new Border + { + Background = new SolidColorBrush(Color.FromArgb(225, 42, 42, 42)), + BorderBrush = Brushes.White, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4), + MinWidth = 72, + Child = CreateControl(wrapper), + }; + return shell; + } + + private void PositionOverlayControls() + { + if (processor.Schematic == null) + return; + + foreach (Control control in overlayCanvas.Children.OfType()) + { + if (control.Tag is not IComponentWrapper wrapper) + continue; + + APoint point = SchematicPointFor(wrapper); + Canvas.SetLeft(control, point.X - control.Bounds.Width / 2); + Canvas.SetTop(control, point.Y - control.Bounds.Height / 2); + } + } + + private APoint SchematicPointFor(IComponentWrapper wrapper) + { + IEnumerable symbols = processor.Schematic?.Symbols.Where(i => WrapperMatchesSymbol(wrapper, i)) ?? Enumerable.Empty(); + if (!symbols.Any()) + return new APoint(16, 16); + + double x = symbols.Average(i => i.Position.x); + double y = symbols.Average(i => i.Position.y); + return schematicCanvas.SchematicToScreen(new Circuit.Point(x, y)); + } + + internal static bool WrapperMatchesSymbol(IComponentWrapper wrapper, Symbol symbol) + { + if (symbol.Component.Name == wrapper.Name) + return true; + + return symbol.Component switch + { + IPotControl pot => pot.Group == wrapper.Name, + IButtonControl button => button.Group == wrapper.Name, + _ => false, + }; + } + + private void ConfigureAutoReload() + { + fileWatcher?.Dispose(); + fileWatcher = null; + + if (autoReloadBox.IsChecked != true || string.IsNullOrEmpty(loadedPath)) + return; + + string? directory = Path.GetDirectoryName(loadedPath); + if (string.IsNullOrEmpty(directory)) + return; + + fileWatcher = new FileSystemWatcher + { + Filter = Path.GetFileName(loadedPath), + Path = directory, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite, + EnableRaisingEvents = true, + }; + fileWatcher.Changed += OnCircuitFileUpdate; + fileWatcher.Renamed += OnCircuitFileUpdate; + } + + private void OnCircuitFileUpdate(object sender, FileSystemEventArgs e) + { + if (!string.Equals(Path.GetFullPath(e.FullPath), Path.GetFullPath(loadedPath ?? string.Empty), StringComparison.OrdinalIgnoreCase)) + return; + + Dispatcher.UIThread.Post(() => + { + if (!string.IsNullOrEmpty(loadedPath) && File.Exists(loadedPath)) + LoadSchematic(loadedPath); + }); + } + private void OversampleBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) { if (oversampleBox.SelectedItem is int value) diff --git a/LiveSPICE.Avalonia/SchematicCanvas.cs b/LiveSPICE.Avalonia/SchematicCanvas.cs index 2fd0d2ff..85b02642 100644 --- a/LiveSPICE.Avalonia/SchematicCanvas.cs +++ b/LiveSPICE.Avalonia/SchematicCanvas.cs @@ -279,6 +279,11 @@ private APoint ToScreen(Circuit.Point point) return new APoint(point.x * Zoom + pan.X, point.y * Zoom + pan.Y); } + internal APoint SchematicToScreen(Circuit.Point point) + { + return ToScreen(point); + } + private Coord ToSchematic(APoint point) { return Snap(new Coord((int)Math.Round((point.X - pan.X) / Zoom), (int)Math.Round((point.Y - pan.Y) / Zoom))); From cd9f21e0d4381cb9dc84ea9167040571c6c839f1 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 21:12:36 +0200 Subject: [PATCH 16/42] Add node probes to Avalonia scope --- .../SimulationAndAudioTests.cs | 12 +++ LiveSPICE.Avalonia/WaveformWindow.cs | 75 +++++++++++++++++-- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index 0ef7673b..9b30d44e 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -50,6 +50,18 @@ public void LinuxAudioDiscoveryFiltersNonAudioPorts() Assert.DoesNotContain(device.InputChannels.Concat(device.OutputChannels), i => i.Name.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void WaveformWindowDiscoversNodeProbeCandidates() + { + Circuit.Circuit circuit = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")).Build(); + + ProbeCandidate[] candidates = WaveformWindow.ProbeCandidates(circuit).ToArray(); + + Assert.NotEmpty(candidates); + Assert.DoesNotContain(candidates, i => string.IsNullOrWhiteSpace(i.Name) || i.Name == "0"); + Assert.DoesNotContain(candidates, i => i.Expression.EqualsZero()); + } + [Fact] public void VirtualAudioStreamInvokesCallbackUntilStopped() { diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index e4854c10..23144dd2 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -6,6 +6,7 @@ using Avalonia.Media; using Avalonia.Threading; using Circuit; +using ComputerAlgebra; using APoint = Avalonia.Point; namespace LiveSPICE.Avalonia; @@ -21,6 +22,7 @@ public sealed class WaveformWindow : Window private readonly TextBox frequency = new TextBox { Text = "440", Width = 72 }; private readonly Slider inputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; + private readonly ListBox probeList = new ListBox { SelectionMode = SelectionMode.Multiple, MinHeight = 96, MaxHeight = 180 }; private readonly TextBlock audioConfig = new TextBlock { TextWrapping = TextWrapping.Wrap }; private readonly TextBox log = new TextBox { IsReadOnly = true, AcceptsReturn = true, MinHeight = 100, TextWrapping = TextWrapping.Wrap }; private readonly LiveAudioProcessor liveProcessor; @@ -89,6 +91,8 @@ private Control BuildContent() audio.Children.Add(new TextBlock { Text = "Generated sine" }); audio.Children.Add(Header("Output")); audio.Children.Add(new TextBlock { Text = "First output channel" }); + audio.Children.Add(Header("Probes")); + audio.Children.Add(probeList); Grid.SetColumn(audio, 0); Grid.SetRowSpan(audio, 2); main.Children.Add(audio); @@ -118,28 +122,39 @@ private void RunSimulation() int sampleRate = 48000; Circuit.Circuit circuit = schematic.Build(); - Simulation simulation = AudioSimulationFactory.Create(circuit, sampleRate, 1, oversampleValue); - simulation.Iterations = iterationValue; + RefreshProbeCandidates(circuit); + Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault() + ?? throw new NotSupportedException("Circuit has no inputs."); + Expression speakerOutput = SpeakerOutputExpression(circuit); + ProbeCandidate[] selectedProbes = SelectedProbeCandidates().ToArray(); + Expression[] outputExpressions = new[] { speakerOutput }.Concat(selectedProbes.Select(i => i.Expression)).ToArray(); + Simulation simulation = new Simulation(AudioSimulationFactory.Solve(circuit, sampleRate, oversampleValue)) + { + Input = new[] { inputExpression }, + Output = outputExpressions, + Oversample = oversampleValue, + Iterations = iterationValue, + }; double inGain = DbToLinear(inputGain.Value); double outGain = DbToLinear(outputGain.Value); double[] input = new double[sampleCount]; - string[] outputNames = settings.AudioOutputs.Count == 0 ? new[] { "Speaker output" } : settings.AudioOutputs.ToArray(); + string[] outputNames = new[] { settings.AudioOutputs.Count == 0 ? "Speaker output" : string.Join(" + ", settings.AudioOutputs) } + .Concat(selectedProbes.Select(i => i.Name)) + .ToArray(); double[][] outputs = outputNames.Select(_ => new double[sampleCount]).ToArray(); for (int i = 0; i < input.Length; i++) input[i] = inGain * 0.25 * Math.Sin(2 * Math.PI * frequencyValue * i / sampleRate); - simulation.Run(input.Length, new[] { input }, new[] { outputs[0] }); + simulation.Run(input.Length, new[] { input }, outputs); for (int channel = 0; channel < outputs.Length; channel++) { - if (channel > 0) - Array.Copy(outputs[0], outputs[channel], outputs[0].Length); for (int i = 0; i < outputs[channel].Length; i++) outputs[channel][i] *= outGain; } waveform.SetTraces(outputNames.Zip(outputs, (name, data) => new WaveformTrace(name, data)), sampleRate); - log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {outputs.SelectMany(i => i).Select(Math.Abs).DefaultIfEmpty().Max():R}"; + log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nProbes: {ChannelNames(selectedProbes.Select(i => i.Name).ToArray())}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {outputs.SelectMany(i => i).Select(Math.Abs).DefaultIfEmpty().Max():R}"; } catch (Exception ex) { @@ -282,6 +297,42 @@ private static double DbToLinear(double db) { return Math.Pow(10, db / 20); } + + private void RefreshProbeCandidates(Circuit.Circuit circuit) + { + string[] selected = SelectedProbeCandidates().Select(i => i.Name).ToArray(); + ProbeCandidate[] candidates = ProbeCandidates(circuit).ToArray(); + probeList.ItemsSource = candidates; + if (probeList.SelectedItems == null) + return; + + probeList.SelectedItems.Clear(); + foreach (ProbeCandidate candidate in candidates.Where(i => selected.Contains(i.Name, StringComparer.OrdinalIgnoreCase))) + probeList.SelectedItems.Add(candidate); + } + + private System.Collections.Generic.IEnumerable SelectedProbeCandidates() + { + return probeList.SelectedItems?.OfType() ?? Enumerable.Empty(); + } + + internal static System.Collections.Generic.IEnumerable ProbeCandidates(Circuit.Circuit circuit) + { + return circuit.Nodes + .Where(i => !string.IsNullOrWhiteSpace(i.Name) && i.Name != "0") + .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(i => new ProbeCandidate(i.Name, i.V)); + } + + private static Expression SpeakerOutputExpression(Circuit.Circuit circuit) + { + Expression expression = 0; + foreach (Speaker speaker in circuit.Components.OfType()) + expression += speaker.Out; + if (expression.EqualsZero()) + throw new NotSupportedException("Circuit has no speaker outputs."); + return expression; + } } public sealed class WaveformView : Control @@ -352,4 +403,12 @@ private static void DrawText(DrawingContext context, string text, APoint point, } } -public sealed record WaveformTrace(string Name, double[] Samples); \ No newline at end of file +public sealed record WaveformTrace(string Name, double[] Samples); + +public sealed record ProbeCandidate(string Name, Expression Expression) +{ + public override string ToString() + { + return Name; + } +} \ No newline at end of file From 32b82fa95d2d97b255ac601514f78a8e3b203af6 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Wed, 6 May 2026 21:14:18 +0200 Subject: [PATCH 17/42] Add expression inputs to Avalonia scope --- .../SimulationAndAudioTests.cs | 20 ++++++++++ LiveSPICE.Avalonia/WaveformWindow.cs | 39 +++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index 9b30d44e..249f894a 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -62,6 +62,26 @@ public void WaveformWindowDiscoversNodeProbeCandidates() Assert.DoesNotContain(candidates, i => i.Expression.EqualsZero()); } + [Fact] + public void WaveformWindowGeneratesFallbackSineInput() + { + double[] input = new double[32]; + + WaveformWindow.FillInputBuffer(input, 48000, 440, 1, null); + + Assert.Contains(input, i => Math.Abs(i) > 1e-9); + } + + [Fact] + public void WaveformWindowGeneratesExpressionInput() + { + double[] input = new double[8]; + + WaveformWindow.FillInputBuffer(input, 48000, 440, 2, "0.125"); + + Assert.All(input, i => Assert.Equal(0.25, i, 12)); + } + [Fact] public void VirtualAudioStreamInvokesCallbackUntilStopped() { diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index 23144dd2..d14c6c1c 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -7,6 +7,7 @@ using Avalonia.Threading; using Circuit; using ComputerAlgebra; +using ComputerAlgebra.LinqCompiler; using APoint = Avalonia.Point; namespace LiveSPICE.Avalonia; @@ -20,6 +21,7 @@ public sealed class WaveformWindow : Window private readonly TextBox iterations = new TextBox { Text = "8", Width = 52 }; private readonly TextBox samples = new TextBox { Text = "4096", Width = 72 }; private readonly TextBox frequency = new TextBox { Text = "440", Width = 72 }; + private readonly TextBox inputExpression = new TextBox { Watermark = "Optional expression in t", MinWidth = 160 }; private readonly Slider inputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; private readonly ListBox probeList = new ListBox { SelectionMode = SelectionMode.Multiple, MinHeight = 96, MaxHeight = 180 }; @@ -65,6 +67,8 @@ private Control BuildContent() toolbar.Children.Add(samples); toolbar.Children.Add(Label("Hz")); toolbar.Children.Add(frequency); + toolbar.Children.Add(Label("Input")); + toolbar.Children.Add(inputExpression); toolbar.Children.Add(Button("Run", (_, _) => RunSimulation())); toolbar.Children.Add(Button("Live", (_, _) => ToggleLive())); DockPanel.SetDock(toolbar, Dock.Top); @@ -143,8 +147,7 @@ private void RunSimulation() .Concat(selectedProbes.Select(i => i.Name)) .ToArray(); double[][] outputs = outputNames.Select(_ => new double[sampleCount]).ToArray(); - for (int i = 0; i < input.Length; i++) - input[i] = inGain * 0.25 * Math.Sin(2 * Math.PI * frequencyValue * i / sampleRate); + FillInputBuffer(input, sampleRate, frequencyValue, inGain); simulation.Run(input.Length, new[] { input }, outputs); for (int channel = 0; channel < outputs.Length; channel++) @@ -154,7 +157,7 @@ private void RunSimulation() } waveform.SetTraces(outputNames.Zip(outputs, (name, data) => new WaveformTrace(name, data)), sampleRate); - log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nProbes: {ChannelNames(selectedProbes.Select(i => i.Name).ToArray())}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nFrequency: {frequencyValue}\nPeak: {outputs.SelectMany(i => i).Select(Math.Abs).DefaultIfEmpty().Max():R}"; + log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nProbes: {ChannelNames(selectedProbes.Select(i => i.Name).ToArray())}\nInput signal: {InputDescription(frequencyValue)}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nPeak: {outputs.SelectMany(i => i).Select(Math.Abs).DefaultIfEmpty().Max():R}"; } catch (Exception ex) { @@ -298,6 +301,36 @@ private static double DbToLinear(double db) return Math.Pow(10, db / 20); } + private void FillInputBuffer(double[] input, int sampleRate, double frequencyValue, double gain) + { + FillInputBuffer(input, sampleRate, frequencyValue, gain, inputExpression.Text); + } + + internal static void FillInputBuffer(double[] input, int sampleRate, double frequencyValue, double gain, string? expressionText) + { + Func? signal = CompileInputExpression(expressionText); + for (int i = 0; i < input.Length; i++) + { + double time = i / (double)sampleRate; + double value = signal == null ? 0.25 * Math.Sin(2 * Math.PI * frequencyValue * time) : signal(time); + input[i] = gain * value; + } + } + + private static Func? CompileInputExpression(string? expressionText) + { + if (string.IsNullOrWhiteSpace(expressionText)) + return null; + + Expression expression = Expression.Parse(expressionText); + return expression.Compile>(Circuit.Component.t); + } + + private string InputDescription(double frequencyValue) + { + return string.IsNullOrWhiteSpace(inputExpression.Text) ? $"Generated sine {frequencyValue} Hz" : inputExpression.Text; + } + private void RefreshProbeCandidates(Circuit.Circuit circuit) { string[] selected = SelectedProbeCandidates().Select(i => i.Name).ToArray(); From 6171d46201360d0df9c5f843c8c5f386272f1c35 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 07:44:25 +0200 Subject: [PATCH 18/42] Fix Avalonia Linux audio driver selection --- .../SimulationAndAudioTests.cs | 13 ++++++- LiveSPICE.Avalonia/AudioConfigWindow.cs | 2 +- LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs | 37 +++++++++++++++++++ LiveSPICE.Avalonia/LinuxAudioDriver.cs | 15 +++++--- LiveSPICE.Avalonia/VirtualAudioDriver.cs | 2 +- LiveSPICE.Avalonia/WaveformWindow.cs | 3 +- 6 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index 249f894a..3d96d7b7 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -34,7 +34,7 @@ public void VirtualAudioDriverProvidesLoopbackDeviceAndChannels() VirtualAudioDriver driver = new VirtualAudioDriver(); Audio.Device device = driver.Devices.Single(); - Assert.Equal("Virtual Audio", driver.Name); + Assert.Equal("LiveSPICE Virtual Audio", driver.Name); Assert.Equal("Managed loopback", device.Name); Assert.Single(device.InputChannels); Assert.Single(device.OutputChannels); @@ -50,6 +50,17 @@ public void LinuxAudioDiscoveryFiltersNonAudioPorts() Assert.DoesNotContain(device.InputChannels.Concat(device.OutputChannels), i => i.Name.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void AvaloniaAudioDriversListsLinuxAndVirtualDriversOnce() + { + Audio.Driver[] drivers = AvaloniaAudioDrivers.Available().ToArray(); + string[] names = drivers.Select(i => i.Name).ToArray(); + + Assert.Contains("PipeWire/JACK", names); + Assert.Contains("LiveSPICE Virtual Audio", names); + Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + [Fact] public void WaveformWindowDiscoversNodeProbeCandidates() { diff --git a/LiveSPICE.Avalonia/AudioConfigWindow.cs b/LiveSPICE.Avalonia/AudioConfigWindow.cs index 7a582551..943e2b06 100644 --- a/LiveSPICE.Avalonia/AudioConfigWindow.cs +++ b/LiveSPICE.Avalonia/AudioConfigWindow.cs @@ -69,7 +69,7 @@ private Control BuildContent() private void PopulateDrivers() { - List availableDrivers = Audio.Driver.Drivers.ToList(); + List availableDrivers = AvaloniaAudioDrivers.Available().ToList(); drivers.ItemsSource = availableDrivers; drivers.SelectedItem = availableDrivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? availableDrivers.FirstOrDefault(); PopulateDevices(); diff --git a/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs b/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs new file mode 100644 index 00000000..6c53c3d2 --- /dev/null +++ b/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace LiveSPICE.Avalonia; + +internal static class AvaloniaAudioDrivers +{ + public static IReadOnlyList Available() + { + List drivers = new List(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + AddIfUsable(drivers, () => new LinuxAudioDriver()); + + AddIfUsable(drivers, () => new VirtualAudioDriver()); + + foreach (Audio.Driver driver in Audio.Driver.Drivers) + if (!drivers.Any(i => string.Equals(i.Name, driver.Name, StringComparison.OrdinalIgnoreCase))) + drivers.Add(driver); + + return drivers; + } + + private static void AddIfUsable(List drivers, Func factory) + { + try + { + Audio.Driver driver = factory(); + if (!drivers.Any(i => string.Equals(i.Name, driver.Name, StringComparison.OrdinalIgnoreCase))) + drivers.Add(driver); + } + catch + { + } + } +} diff --git a/LiveSPICE.Avalonia/LinuxAudioDriver.cs b/LiveSPICE.Avalonia/LinuxAudioDriver.cs index 48459d19..28528e7f 100644 --- a/LiveSPICE.Avalonia/LinuxAudioDriver.cs +++ b/LiveSPICE.Avalonia/LinuxAudioDriver.cs @@ -68,7 +68,7 @@ internal sealed unsafe class JackAudioStream : Audio.Stream public JackAudioStream(SampleHandler callback, LinuxAudioChannel[] input, LinuxAudioChannel[] output) : base(input, output) { this.callback = callback; - IntPtr status; + int status; client = JackNative.jack_client_open("LiveSPICE", JackNullOption, out status); if (client == IntPtr.Zero) throw new InvalidOperationException("Could not open JACK client. Is JACK or PipeWire JACK running?"); @@ -89,9 +89,9 @@ public JackAudioStream(SampleHandler callback, LinuxAudioChannel[] input, LinuxA JackNative.Check(JackNative.jack_activate(client), "activate JACK client"); for (int i = 0; i < input.Length; i++) - JackNative.jack_connect(client, input[i].Name, JackNative.jack_port_name(inputPorts[i])); + JackNative.jack_connect(client, input[i].Name, JackNative.PortName(inputPorts[i])); for (int i = 0; i < output.Length; i++) - JackNative.jack_connect(client, JackNative.jack_port_name(outputPorts[i]), output[i].Name); + JackNative.jack_connect(client, JackNative.PortName(outputPorts[i]), output[i].Name); } public override double SampleRate => sampleRate; @@ -174,7 +174,7 @@ private void EnsureBuffers(int frameCount) internal static class JackNative { [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] - public static extern IntPtr jack_client_open(string clientName, uint options, out IntPtr status); + public static extern IntPtr jack_client_open(string clientName, uint options, out int status); [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] public static extern int jack_client_close(IntPtr client); @@ -198,11 +198,16 @@ internal static class JackNative public static extern IntPtr jack_port_get_buffer(IntPtr port, uint frames); [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] - public static extern string jack_port_name(IntPtr port); + private static extern IntPtr jack_port_name(IntPtr port); [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] public static extern int jack_connect(IntPtr client, string sourcePort, string destinationPort); + public static string PortName(IntPtr port) + { + return Marshal.PtrToStringAnsi(jack_port_name(port)) ?? string.Empty; + } + public static void Check(int result, string operation) { if (result != 0) diff --git a/LiveSPICE.Avalonia/VirtualAudioDriver.cs b/LiveSPICE.Avalonia/VirtualAudioDriver.cs index 8acd5911..2e9f1633 100644 --- a/LiveSPICE.Avalonia/VirtualAudioDriver.cs +++ b/LiveSPICE.Avalonia/VirtualAudioDriver.cs @@ -10,7 +10,7 @@ public VirtualAudioDriver() devices.Add(new VirtualAudioDevice()); } - public override string Name => "Virtual Audio"; + public override string Name => "LiveSPICE Virtual Audio"; } internal sealed class VirtualAudioChannel : Audio.Channel diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs index d14c6c1c..df75c8f9 100644 --- a/LiveSPICE.Avalonia/WaveformWindow.cs +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -244,7 +244,8 @@ internal void TestProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audi private Audio.Device SelectedDevice() { - Audio.Driver driver = Audio.Driver.Drivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? Audio.Driver.Drivers.First(); + IReadOnlyList drivers = AvaloniaAudioDrivers.Available(); + Audio.Driver driver = drivers.FirstOrDefault(i => i.Name == settings.AudioDriver && i.Devices.Any()) ?? drivers.First(i => i.Devices.Any()); return driver.Devices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? driver.Devices.First(); } From 5f99ea91f8fb77b3b969bdac2ed9a7d27a9d1ef7 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 07:49:52 +0200 Subject: [PATCH 19/42] Clarify Avalonia audio configuration labels --- .../SimulationAndAudioTests.cs | 7 +++++ LiveSPICE.Avalonia/AudioConfigWindow.cs | 26 +++++++++++++++++-- LiveSPICE.Avalonia/LinuxAudioDriver.cs | 14 +++++++++- LiveSPICE.Avalonia/VirtualAudioDriver.cs | 10 +++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index 3d96d7b7..0be8a9f6 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -35,7 +35,9 @@ public void VirtualAudioDriverProvidesLoopbackDeviceAndChannels() Audio.Device device = driver.Devices.Single(); Assert.Equal("LiveSPICE Virtual Audio", driver.Name); + Assert.Equal("LiveSPICE Virtual Audio", driver.ToString()); Assert.Equal("Managed loopback", device.Name); + Assert.Equal("Managed loopback", device.ToString()); Assert.Single(device.InputChannels); Assert.Single(device.OutputChannels); } @@ -46,8 +48,13 @@ public void LinuxAudioDiscoveryFiltersNonAudioPorts() LinuxAudioDriver driver = new LinuxAudioDriver(); Assert.Equal("PipeWire/JACK", driver.Name); + Assert.Equal("PipeWire/JACK", driver.ToString()); foreach (Audio.Device device in driver.Devices) + { + Assert.Equal("PipeWire/JACK port graph", device.Name); + Assert.Equal("PipeWire/JACK port graph", device.ToString()); Assert.DoesNotContain(device.InputChannels.Concat(device.OutputChannels), i => i.Name.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase)); + } } [Fact] diff --git a/LiveSPICE.Avalonia/AudioConfigWindow.cs b/LiveSPICE.Avalonia/AudioConfigWindow.cs index 943e2b06..a9c63c12 100644 --- a/LiveSPICE.Avalonia/AudioConfigWindow.cs +++ b/LiveSPICE.Avalonia/AudioConfigWindow.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Data; namespace LiveSPICE.Avalonia; @@ -27,6 +28,8 @@ public AudioConfigWindow(AppSettings settings) MinHeight = 360; drivers.SelectionChanged += (_, _) => PopulateDevices(); devices.SelectionChanged += (_, _) => PopulateChannels(); + drivers.DisplayMemberBinding = new Binding(nameof(Audio.Driver.Name)); + devices.DisplayMemberBinding = new Binding(nameof(Audio.Device.Name)); Content = BuildContent(); PopulateDrivers(); Closed += (_, _) => StopTest(); @@ -52,7 +55,7 @@ private Control BuildContent() StackPanel panel = new StackPanel { Spacing = 8, Margin = new global::Avalonia.Thickness(12) }; panel.Children.Add(Label("Driver")); panel.Children.Add(drivers); - panel.Children.Add(Label("Device")); + panel.Children.Add(Label("Device / port graph")); panel.Children.Add(devices); Grid channels = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), ColumnSpacing = 10 }; @@ -73,7 +76,7 @@ private void PopulateDrivers() drivers.ItemsSource = availableDrivers; drivers.SelectedItem = availableDrivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? availableDrivers.FirstOrDefault(); PopulateDevices(); - status.Text = availableDrivers.Count == 0 ? "No audio drivers are available in this build." : "Ready"; + status.Text = availableDrivers.Count == 0 ? "No audio drivers are available in this build." : DriverHint(drivers.SelectedItem as Audio.Driver); } private void PopulateDevices() @@ -83,6 +86,7 @@ private void PopulateDevices() devices.ItemsSource = availableDevices; devices.SelectedItem = availableDevices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? availableDevices.FirstOrDefault(); PopulateChannels(); + status.Text = driver == null ? "No audio driver selected." : DriverHint(driver); } private void PopulateChannels() @@ -92,6 +96,8 @@ private void PopulateChannels() outputs.ItemsSource = device?.OutputChannels ?? Array.Empty(); SelectSaved(inputs, settings.AudioInputs); SelectSaved(outputs, settings.AudioOutputs); + if (device != null) + status.Text = DeviceHint(device); } private void SaveAndClose() @@ -187,6 +193,22 @@ private static void SelectSaved(ListBox list, IEnumerable names) list.SelectedItems.Add(item); } + private static string DriverHint(Audio.Driver? driver) + { + if (driver == null) + return "No audio driver selected."; + return driver.Name == "PipeWire/JACK" + ? "PipeWire/JACK exposes individual ports below; choose capture and playback ports there." + : "Ready"; + } + + private static string DeviceHint(Audio.Device device) + { + return device.Name == LinuxAudioDevice.DeviceName + ? "Select actual PipeWire/JACK input and output ports below." + : "Ready"; + } + private static Border ChannelPanel(string title, ListBox list) { DockPanel dock = new DockPanel(); diff --git a/LiveSPICE.Avalonia/LinuxAudioDriver.cs b/LiveSPICE.Avalonia/LinuxAudioDriver.cs index 28528e7f..f4ddf31a 100644 --- a/LiveSPICE.Avalonia/LinuxAudioDriver.cs +++ b/LiveSPICE.Avalonia/LinuxAudioDriver.cs @@ -17,6 +17,11 @@ public LinuxAudioDriver() } public override string Name => "PipeWire/JACK"; + + public override string ToString() + { + return Name; + } } internal sealed class LinuxAudioChannel : Audio.Channel @@ -36,12 +41,19 @@ public override string ToString() internal sealed class LinuxAudioDevice : Audio.Device { - public LinuxAudioDevice(Audio.Channel[] input, Audio.Channel[] output) : base("System audio ports") + public const string DeviceName = "PipeWire/JACK port graph"; + + public LinuxAudioDevice(Audio.Channel[] input, Audio.Channel[] output) : base(DeviceName) { inputs = input; outputs = output; } + public override string ToString() + { + return Name; + } + public override Audio.Stream Open(Audio.Stream.SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) { return new JackAudioStream(callback, input.Cast().ToArray(), output.Cast().ToArray()); diff --git a/LiveSPICE.Avalonia/VirtualAudioDriver.cs b/LiveSPICE.Avalonia/VirtualAudioDriver.cs index 2e9f1633..63c543fb 100644 --- a/LiveSPICE.Avalonia/VirtualAudioDriver.cs +++ b/LiveSPICE.Avalonia/VirtualAudioDriver.cs @@ -11,6 +11,11 @@ public VirtualAudioDriver() } public override string Name => "LiveSPICE Virtual Audio"; + + public override string ToString() + { + return Name; + } } internal sealed class VirtualAudioChannel : Audio.Channel @@ -38,6 +43,11 @@ public VirtualAudioDevice() : base("Managed loopback") outputs = new Audio.Channel[] { new VirtualAudioChannel("Output 1") }; } + public override string ToString() + { + return Name; + } + public override Audio.Stream Open(Audio.Stream.SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) { return new VirtualAudioStream(callback, input, output); From 5c3cec94305b7a2ab49db1cb9d49800248941c6d Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 07:52:13 +0200 Subject: [PATCH 20/42] Prefer JACK names for Linux audio ports --- .../SimulationAndAudioTests.cs | 10 +++++++++ LiveSPICE.Avalonia/LinuxAudioDriver.cs | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index 0be8a9f6..19819a64 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -68,6 +68,16 @@ public void AvaloniaAudioDriversListsLinuxAndVirtualDriversOnce() Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); } + [Fact] + public void LinuxAudioDiscoveryPrefersJackPortNamesOverRawPipeWireNodes() + { + string[] ports = LinuxAudioDiscovery.PreferredPorts( + new[] { "Built-in Audio Analog Stereo:capture_FL", "Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0" }, + new[] { "alsa_input.pci-0000_00_1f.3.analog-stereo:capture_FL" }).ToArray(); + + Assert.Equal(new[] { "Built-in Audio Analog Stereo:capture_FL" }, ports); + } + [Fact] public void WaveformWindowDiscoversNodeProbeCandidates() { diff --git a/LiveSPICE.Avalonia/LinuxAudioDriver.cs b/LiveSPICE.Avalonia/LinuxAudioDriver.cs index f4ddf31a..2e30a628 100644 --- a/LiveSPICE.Avalonia/LinuxAudioDriver.cs +++ b/LiveSPICE.Avalonia/LinuxAudioDriver.cs @@ -231,22 +231,29 @@ internal static class LinuxAudioDiscovery { public static IEnumerable InputPorts() { - return AudioPorts("pw-link", "-o") - .Concat(AudioPorts("jack_lsp", null).Where(i => i.Contains(":capture_", StringComparison.OrdinalIgnoreCase))) - .Where(IsAudioPort) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(i => i, StringComparer.OrdinalIgnoreCase); + return PreferredPorts(JackPorts(":capture_", ":monitor_"), AudioPorts("pw-link", "-o")); } public static IEnumerable OutputPorts() { - return AudioPorts("pw-link", "-i") - .Concat(AudioPorts("jack_lsp", null).Where(i => i.Contains(":playback_", StringComparison.OrdinalIgnoreCase))) + return PreferredPorts(JackPorts(":playback_"), AudioPorts("pw-link", "-i")); + } + + internal static IEnumerable PreferredPorts(IEnumerable jackPorts, IEnumerable pipeWirePorts) + { + string[] preferred = jackPorts.Where(IsAudioPort).ToArray(); + return (preferred.Length > 0 ? preferred : pipeWirePorts) .Where(IsAudioPort) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(i => i, StringComparer.OrdinalIgnoreCase); } + private static IEnumerable JackPorts(params string[] portKinds) + { + return AudioPorts("jack_lsp", null) + .Where(port => portKinds.Any(kind => port.Contains(kind, StringComparison.OrdinalIgnoreCase))); + } + private static IEnumerable AudioPorts(string command, string? arguments) { try From 95b2c5b96837017fa497f5dbd9ea47a0dc6fcfc0 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 07:58:02 +0200 Subject: [PATCH 21/42] Add JACK live mode hardware test --- .../SimulationAndAudioTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index 19819a64..d2779f11 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -150,6 +150,84 @@ public async Task LiveAudioProcessorCanRunOffUiThread() Assert.Contains(output.Samples, i => Math.Abs(i) > 1e-12); } + [Fact] + public void LinuxJackLiveModeProcessesBuiltInMicrophoneThroughRcFilter() + { + if (!string.Equals(Environment.GetEnvironmentVariable("LIVESPICE_RUN_JACK_HARDWARE_TEST"), "1", StringComparison.Ordinal)) + return; + + LinuxAudioDriver driver = new LinuxAudioDriver(); + Audio.Device? device = driver.Devices.SingleOrDefault(); + Assert.NotNull(device); + + Audio.Channel? microphone = BuiltIn(device!.InputChannels, ":capture_FL") ?? BuiltIn(device.InputChannels, ":capture_"); + Audio.Channel? playback = BuiltIn(device.OutputChannels, ":playback_FL") ?? BuiltIn(device.OutputChannels, ":playback_"); + Assert.NotNull(microphone); + Assert.NotNull(playback); + + Schematic schematic = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + LiveAudioProcessor liveProcessor = new LiveAudioProcessor(schematic); + List capturedInputs = new List(); + List capturedOutputs = new List(); + using ManualResetEventSlim completed = new ManualResetEventSlim(false); + int callbackCount = 0; + + Audio.Stream stream = device.Open((count, input, output, rate) => + { + if (callbackCount == 0) + liveProcessor.Start(rate, 4, 8); + + double[] inputCopy = input.Length == 0 ? new double[count] : input[0].Samples.Take(count).ToArray(); + liveProcessor.Process(count, input, output, rate); + double[] outputCopy = output.Length == 0 ? new double[count] : output[0].Samples.Take(count).ToArray(); + + lock (capturedInputs) + { + capturedInputs.Add(inputCopy); + capturedOutputs.Add(outputCopy); + callbackCount++; + if (callbackCount >= 8) + completed.Set(); + } + }, new[] { microphone! }, new[] { playback! }); + + try + { + Assert.True(completed.Wait(TimeSpan.FromSeconds(4)), "Timed out waiting for PipeWire/JACK live audio callbacks."); + } + finally + { + stream.Stop(); + liveProcessor.Stop(); + } + + LiveAudioProcessor referenceProcessor = new LiveAudioProcessor(schematic); + referenceProcessor.Start(stream.SampleRate, 4, 8); + for (int block = 0; block < capturedInputs.Count; block++) + { + using Audio.SampleBuffer input = Buffer(capturedInputs[block]); + using Audio.SampleBuffer output = new Audio.SampleBuffer(capturedInputs[block].Length); + double[] expected = referenceProcessor.Process(capturedInputs[block].Length, new[] { input }, new[] { output }, stream.SampleRate); + + Assert.Equal(expected.Length, capturedOutputs[block].Length); + for (int sample = 0; sample < expected.Length; sample++) + Assert.Equal(expected[sample], capturedOutputs[block][sample], 12); + } + referenceProcessor.Stop(); + } + + private static Audio.Channel? BuiltIn(IEnumerable channels, string suffix) + { + return channels.FirstOrDefault(i => i.Name.Contains("Built-in Audio", StringComparison.OrdinalIgnoreCase) && i.Name.Contains(suffix, StringComparison.OrdinalIgnoreCase)); + } + + private static Audio.SampleBuffer Buffer(double[] samples) + { + Audio.SampleBuffer buffer = new Audio.SampleBuffer(samples.Length); + Array.Copy(samples, buffer.Samples, samples.Length); + return buffer; + } + private static string FindFixture(string relativePath) { DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); From 991f540dd74ad83696914ec972db6b9a170739bf Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:00:34 +0200 Subject: [PATCH 22/42] Strengthen JACK live mode hardware assertions --- .../SimulationAndAudioTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs index d2779f11..7cb14129 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -201,8 +201,14 @@ public void LinuxJackLiveModeProcessesBuiltInMicrophoneThroughRcFilter() liveProcessor.Stop(); } + double inputRms = Rms(capturedInputs.SelectMany(i => i)); + Assert.True(inputRms > 1e-5, $"Captured built-in microphone input is too close to silence. RMS={inputRms:R}."); + LiveAudioProcessor referenceProcessor = new LiveAudioProcessor(schematic); referenceProcessor.Start(stream.SampleRate, 4, 8); + List expectedSamples = new List(); + List actualSamples = new List(); + List inputSamples = new List(); for (int block = 0; block < capturedInputs.Count; block++) { using Audio.SampleBuffer input = Buffer(capturedInputs[block]); @@ -212,8 +218,22 @@ public void LinuxJackLiveModeProcessesBuiltInMicrophoneThroughRcFilter() Assert.Equal(expected.Length, capturedOutputs[block].Length); for (int sample = 0; sample < expected.Length; sample++) Assert.Equal(expected[sample], capturedOutputs[block][sample], 12); + + expectedSamples.AddRange(expected); + actualSamples.AddRange(capturedOutputs[block]); + inputSamples.AddRange(capturedInputs[block]); } referenceProcessor.Stop(); + + double expectedRms = Rms(expectedSamples); + double actualRms = Rms(actualSamples); + double errorRms = Rms(expectedSamples.Zip(actualSamples, (expected, actual) => expected - actual)); + double copyErrorRms = Rms(inputSamples.Zip(actualSamples, (input, actual) => input - actual)); + + Assert.True(expectedRms > 1e-8, $"Expected RC output is too close to silence. RMS={expectedRms:R}."); + Assert.True(actualRms > 1e-8, $"Live RC output is too close to silence. RMS={actualRms:R}."); + Assert.True(errorRms <= Math.Max(1e-10, expectedRms * 1e-8), $"Live output does not match the RC prediction. Error RMS={errorRms:R}, expected RMS={expectedRms:R}."); + Assert.True(copyErrorRms > inputRms * 1e-3, $"Live output looks like an unfiltered copy of the input. Copy error RMS={copyErrorRms:R}, input RMS={inputRms:R}."); } private static Audio.Channel? BuiltIn(IEnumerable channels, string suffix) @@ -228,6 +248,18 @@ private static Audio.SampleBuffer Buffer(double[] samples) return buffer; } + private static double Rms(IEnumerable samples) + { + double sum = 0; + int count = 0; + foreach (double sample in samples) + { + sum += sample * sample; + count++; + } + return count == 0 ? 0 : Math.Sqrt(sum / count); + } + private static string FindFixture(string relativePath) { DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); From 48d8aa419273fa5164032fec7864710bdf5e9d9d Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:09:47 +0200 Subject: [PATCH 23/42] Wire Linux plugin GUI parity tests --- LiveSPICE.Avalonia.Tests/PluginPortTests.cs | 168 +++++++++++++++++- LiveSPICE.Avalonia/PluginEditorWindow.cs | 16 ++ .../LiveSPICE.PluginLinux.csproj | 1 + LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs | 8 +- 4 files changed, 191 insertions(+), 2 deletions(-) diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs index 10c32d07..0ac41355 100644 --- a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -9,6 +9,8 @@ namespace LiveSPICE.Avalonia.Tests; public class PluginPortTests { + private static bool avaloniaInitialized; + [Fact] public void LinuxPluginInitializesMonoPorts() { @@ -20,7 +22,42 @@ public void LinuxPluginInitializesMonoPorts() Assert.Single(plugin.OutputPorts); Assert.Equal(EAudioChannelConfiguration.Mono, plugin.InputPorts[0].ChannelConfiguration); Assert.Equal(EAudioChannelConfiguration.Mono, plugin.OutputPorts[0].ChannelConfiguration); - Assert.False(plugin.HasUserInterface); + Assert.True(plugin.HasUserInterface); + Assert.Equal(700u, plugin.EditorWidth); + Assert.Equal(420u, plugin.EditorHeight); + } + + [Fact] + public void LinuxPluginCreatesAvaloniaEditorBoundToProcessor() + { + EnsureAvalonia(); + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + PluginEditorWindow editor = plugin.CreateEditorWindow(); + + Assert.Equal("Load Schematic", editor.TestLoadedText); + Assert.Equal(0, editor.TestControlPanelCount); + Assert.Equal(0, editor.TestOverlayControlCount); + + plugin.LoadSchematic(FindFixture("Tests/Circuits/59 Bassman Preamp.schx")); + editor.LoadSchematic(plugin.SchematicPath); + + Assert.Equal(plugin.SimulationProcessor.SchematicName, editor.TestLoadedText); + Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestControlPanelCount); + Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestOverlayControlCount); + } + + [Fact] + public void PluginEditorSettingsUpdateSharedProcessor() + { + EnsureAvalonia(); + SimulationProcessor processor = new SimulationProcessor(); + PluginEditorWindow editor = new PluginEditorWindow(processor); + + editor.TestSelectedOversample = 8; + editor.TestSelectedIterations = 32; + + Assert.Equal(8, processor.Oversample); + Assert.Equal(32, processor.Iterations); } [Fact] @@ -44,15 +81,20 @@ public void PluginProgramParametersRoundTripProcessorSettings() public void LinuxPluginStateRoundTripsProcessorSettings() { LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + string schematicPath = FindFixture("Tests/Circuits/59 Bassman Preamp.schx"); + plugin.LoadSchematic(schematicPath); plugin.SimulationProcessor.Oversample = 8; plugin.SimulationProcessor.Iterations = 32; + SetDistinctControlValues(plugin.SimulationProcessor); byte[] state = plugin.SaveState(); LiveSPICELinuxPlugin restored = new LiveSPICELinuxPlugin(); restored.RestoreState(state); + Assert.Equal(schematicPath, restored.SchematicPath); Assert.Equal(8, restored.SimulationProcessor.Oversample); Assert.Equal(32, restored.SimulationProcessor.Iterations); + AssertControlValuesEqual(plugin.SimulationProcessor, restored.SimulationProcessor); } [Fact] @@ -66,4 +108,128 @@ public void PluginEditorCreatesOverlayControlsForInteractiveSchematic() Assert.All(processor.InteractiveComponents, wrapper => Assert.Contains(processor.Schematic!.Symbols, symbol => PluginEditorWindow.WrapperMatchesSymbol(wrapper, symbol))); } + + [Fact] + public void SimulationProcessorPassesThroughWhenNoSchematicIsLoaded() + { + SimulationProcessor processor = new SimulationProcessor(); + double[][] input = new[] { Enumerable.Range(0, 64).Select(i => Math.Sin(i / 8.0)).ToArray() }; + double[][] output = new[] { new double[64] }; + + processor.RunSimulation(input, output, output[0].Length); + + Assert.Equal(input[0], output[0]); + } + + [Fact] + public void SimulationProcessorProcessesLoadedRcSchematic() + { + SimulationProcessor processor = new SimulationProcessor + { + SampleRate = 48000, + Oversample = 4, + Iterations = 8, + }; + processor.LoadSchematic(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + double[][] input = new[] { Enumerable.Range(0, 512).Select(i => Math.Sin(2 * Math.PI * 440 * i / 48000)).ToArray() }; + double[][] output = new[] { new double[512] }; + + RunUntilReady(processor, input, output, output[0].Length); + + Assert.Contains(output[0], i => Math.Abs(i) > 1e-9); + Assert.NotEqual(input[0], output[0]); + Assert.DoesNotContain(output[0], double.IsNaN); + } + + private static void RunUntilReady(SimulationProcessor processor, double[][] input, double[][] output, int length) + { + Exception? lastException = null; + for (int attempt = 0; attempt < 20; attempt++) + { + Array.Clear(output[0]); + try + { + processor.RunSimulation(input, output, length); + if (output[0].Any(i => Math.Abs(i) > 1e-12) && !output[0].SequenceEqual(input[0])) + return; + } + catch (NullReferenceException ex) + { + lastException = ex; + } + Thread.Sleep(50); + } + + if (lastException != null) + throw lastException; + } + + private static void SetDistinctControlValues(SimulationProcessor processor) + { + int position = 0; + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + { + switch (wrapper) + { + case PotWrapper potWrapper: + potWrapper.PotValue = 0.1 + position * 0.03; + break; + case DoubleThrowWrapper doubleThrowWrapper: + doubleThrowWrapper.Engaged = position % 2 == 0; + break; + case MultiThrowWrapper multiThrowWrapper: + multiThrowWrapper.Position = position % 3; + break; + } + position++; + } + } + + private static void AssertControlValuesEqual(SimulationProcessor expected, SimulationProcessor actual) + { + Assert.Equal(expected.InteractiveComponents.Count, actual.InteractiveComponents.Count); + for (int index = 0; index < expected.InteractiveComponents.Count; index++) + { + IComponentWrapper expectedWrapper = expected.InteractiveComponents[index]; + IComponentWrapper actualWrapper = actual.InteractiveComponents[index]; + Assert.Equal(expectedWrapper.Name, actualWrapper.Name); + switch (expectedWrapper) + { + case PotWrapper expectedPot: + PotWrapper actualPot = Assert.IsType(actualWrapper); + Assert.Equal(expectedPot.PotValue, actualPot.PotValue, 12); + break; + case DoubleThrowWrapper expectedSwitch: + DoubleThrowWrapper actualDoubleThrow = Assert.IsType(actualWrapper); + Assert.Equal(expectedSwitch.Engaged, actualDoubleThrow.Engaged); + break; + case MultiThrowWrapper expectedSwitch: + MultiThrowWrapper actualMultiThrow = Assert.IsType(actualWrapper); + Assert.Equal(expectedSwitch.Position, actualMultiThrow.Position); + break; + } + } + } + + private static void EnsureAvalonia() + { + if (avaloniaInitialized) + return; + + Program.BuildAvaloniaApp().SetupWithoutStarting(); + avaloniaInitialized = true; + } + + private static string FindFixture(string relativePath) + { + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException("Could not locate test fixture.", relativePath); + } } diff --git a/LiveSPICE.Avalonia/PluginEditorWindow.cs b/LiveSPICE.Avalonia/PluginEditorWindow.cs index 78c4dcd3..5cd0130d 100644 --- a/LiveSPICE.Avalonia/PluginEditorWindow.cs +++ b/LiveSPICE.Avalonia/PluginEditorWindow.cs @@ -65,6 +65,22 @@ public void LoadSchematic(string path) internal int TestOverlayControlCount => overlayCanvas.Children.Count; + internal string TestLoadedText => loadedText.Text ?? string.Empty; + + internal int TestControlPanelCount => controlsPanel.Children.Count; + + internal object? TestSelectedOversample + { + get => oversampleBox.SelectedItem; + set => oversampleBox.SelectedItem = value; + } + + internal object? TestSelectedIterations + { + get => iterationsBox.SelectedItem; + set => iterationsBox.SelectedItem = value; + } + internal APoint TestSchematicPointFor(IComponentWrapper wrapper) { return SchematicPointFor(wrapper); diff --git a/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj index 912c01d5..af06474b 100644 --- a/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj +++ b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj @@ -8,6 +8,7 @@ enable + diff --git a/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs index c27a99df..02ffda9e 100644 --- a/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs +++ b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Xml.Serialization; using AudioPlugSharp; +using LiveSPICE.Avalonia; using LiveSPICE.PluginCore; namespace LiveSPICE.PluginLinux; @@ -22,7 +23,7 @@ public LiveSPICELinuxPlugin() PluginCategory = "Fx"; PluginVersion = "1.1.0"; PluginID = 0xDC8558DC41A44872; - HasUserInterface = false; + HasUserInterface = true; EditorWidth = 700; EditorHeight = 420; SimulationProcessor = new SimulationProcessor(); @@ -32,6 +33,11 @@ public LiveSPICELinuxPlugin() public string SchematicPath => SimulationProcessor.SchematicPath; + public PluginEditorWindow CreateEditorWindow() + { + return new PluginEditorWindow(SimulationProcessor); + } + public override void Initialize() { base.Initialize(); From 5aebc6da8054a92b071d30acae8259b9adcebea6 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:20:35 +0200 Subject: [PATCH 24/42] Add MXR Phase 90 plugin coverage --- LiveSPICE.Avalonia.Tests/PluginPortTests.cs | 96 +++++++++++++++++---- 1 file changed, 80 insertions(+), 16 deletions(-) diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs index 0ac41355..1a34d0bb 100644 --- a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -1,5 +1,6 @@ using AudioPlugSharp; using System.IO; +using Avalonia.Threading; using LiveSPICE.Avalonia; using LiveSPICE.PluginCore; using LiveSPICE.PluginLinux; @@ -32,18 +33,24 @@ public void LinuxPluginCreatesAvaloniaEditorBoundToProcessor() { EnsureAvalonia(); LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); - PluginEditorWindow editor = plugin.CreateEditorWindow(); - - Assert.Equal("Load Schematic", editor.TestLoadedText); - Assert.Equal(0, editor.TestControlPanelCount); - Assert.Equal(0, editor.TestOverlayControlCount); + PluginEditorWindow editor = RunOnUiThread(plugin.CreateEditorWindow); + try + { + Assert.Equal("Load Schematic", editor.TestLoadedText); + Assert.Equal(0, editor.TestControlPanelCount); + Assert.Equal(0, editor.TestOverlayControlCount); - plugin.LoadSchematic(FindFixture("Tests/Circuits/59 Bassman Preamp.schx")); - editor.LoadSchematic(plugin.SchematicPath); + plugin.LoadSchematic(FindFixture("Tests/Circuits/59 Bassman Preamp.schx")); + editor.LoadSchematic(plugin.SchematicPath); - Assert.Equal(plugin.SimulationProcessor.SchematicName, editor.TestLoadedText); - Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestControlPanelCount); - Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestOverlayControlCount); + Assert.Equal(plugin.SimulationProcessor.SchematicName, editor.TestLoadedText); + Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestControlPanelCount); + Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestOverlayControlCount); + } + finally + { + CloseOnUiThread(editor); + } } [Fact] @@ -51,13 +58,19 @@ public void PluginEditorSettingsUpdateSharedProcessor() { EnsureAvalonia(); SimulationProcessor processor = new SimulationProcessor(); - PluginEditorWindow editor = new PluginEditorWindow(processor); - - editor.TestSelectedOversample = 8; - editor.TestSelectedIterations = 32; + PluginEditorWindow editor = RunOnUiThread(() => new PluginEditorWindow(processor)); + try + { + editor.TestSelectedOversample = 8; + editor.TestSelectedIterations = 32; - Assert.Equal(8, processor.Oversample); - Assert.Equal(32, processor.Iterations); + Assert.Equal(8, processor.Oversample); + Assert.Equal(32, processor.Iterations); + } + finally + { + CloseOnUiThread(editor); + } } [Fact] @@ -141,6 +154,42 @@ public void SimulationProcessorProcessesLoadedRcSchematic() Assert.DoesNotContain(output[0], double.IsNaN); } + [Fact] + public void PluginLoadsMxrPhase90WithControlsAndProducesStableAudio() + { + EnsureAvalonia(); + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + string schematicPath = FindFixture("Tests/Examples/MXR Phase 90.schx"); + plugin.LoadSchematic(schematicPath); + + PotWrapper[] pots = plugin.SimulationProcessor.InteractiveComponents.OfType().ToArray(); + Assert.Equal(new[] { "Speed", "Trimmer" }, pots.Select(i => i.Name).OrderBy(i => i).ToArray()); + + PluginEditorWindow editor = RunOnUiThread(plugin.CreateEditorWindow); + try + { + editor.LoadSchematic(schematicPath); + Assert.Equal("MXR Phase 90", editor.TestLoadedText); + Assert.Equal(2, editor.TestControlPanelCount); + Assert.Equal(2, editor.TestOverlayControlCount); + } + finally + { + CloseOnUiThread(editor); + } + + double[][] input = new[] { Enumerable.Range(0, 1024).Select(i => 0.1 * Math.Sin(2 * Math.PI * 220 * i / 48000)).ToArray() }; + double[][] output = new[] { new double[1024] }; + plugin.SimulationProcessor.SampleRate = 48000; + plugin.SimulationProcessor.Oversample = 4; + plugin.SimulationProcessor.Iterations = 8; + + RunUntilReady(plugin.SimulationProcessor, input, output, output[0].Length); + + Assert.Contains(output[0], i => Math.Abs(i) > 1e-9); + Assert.DoesNotContain(output[0], double.IsNaN); + } + private static void RunUntilReady(SimulationProcessor processor, double[][] input, double[][] output, int length) { Exception? lastException = null; @@ -220,6 +269,21 @@ private static void EnsureAvalonia() avaloniaInitialized = true; } + private static T RunOnUiThread(Func action) + { + return Dispatcher.UIThread.CheckAccess() + ? action() + : Dispatcher.UIThread.Invoke(action); + } + + private static void CloseOnUiThread(PluginEditorWindow editor) + { + if (Dispatcher.UIThread.CheckAccess()) + editor.Close(); + else + Dispatcher.UIThread.Invoke(editor.Close); + } + private static string FindFixture(string relativePath) { DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); From 1c9634d608e467887e49f30a561ed38b82d92358 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:35:27 +0200 Subject: [PATCH 25/42] Support default schematic plugin builds --- LiveSPICE.Avalonia.Tests/AssemblyInfo.cs | 3 +++ LiveSPICE.Avalonia.Tests/PluginPortTests.cs | 10 ++++++++ .../LiveSPICE.PluginLinux.csproj | 3 +++ LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs | 24 +++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 LiveSPICE.Avalonia.Tests/AssemblyInfo.cs diff --git a/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs b/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..21712008 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs index 1a34d0bb..6fc73443 100644 --- a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -28,6 +28,16 @@ public void LinuxPluginInitializesMonoPorts() Assert.Equal(420u, plugin.EditorHeight); } + [Fact] + public void LinuxPluginStartsWithoutHardcodedSchematicByDefault() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + + Assert.Null(plugin.SimulationProcessor.Schematic); + Assert.Empty(plugin.SchematicPath); + Assert.DoesNotContain("LiveSPICE.PluginLinux.DefaultSchematic.schx", typeof(LiveSPICELinuxPlugin).Assembly.GetManifestResourceNames()); + } + [Fact] public void LinuxPluginCreatesAvaloniaEditorBoundToProcessor() { diff --git a/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj index af06474b..3aa94cee 100644 --- a/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj +++ b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj @@ -14,4 +14,7 @@ + + + diff --git a/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs index 02ffda9e..523b4762 100644 --- a/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs +++ b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Reflection; using System.Xml.Serialization; using AudioPlugSharp; using LiveSPICE.Avalonia; @@ -27,6 +28,7 @@ public LiveSPICELinuxPlugin() EditorWidth = 700; EditorHeight = 420; SimulationProcessor = new SimulationProcessor(); + LoadDefaultSchematic(); } public SimulationProcessor SimulationProcessor { get; } @@ -57,6 +59,28 @@ public void LoadSchematic(string path) SimulationProcessor.LoadSchematic(path); } + private void LoadDefaultSchematic() + { + Assembly assembly = typeof(LiveSPICELinuxPlugin).Assembly; + string? resourceName = assembly.GetManifestResourceNames().SingleOrDefault(i => i == "LiveSPICE.PluginLinux.DefaultSchematic.schx"); + if (resourceName == null) + return; + + string defaultSchematicPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LiveSPICE", + "PluginLinux", + "DefaultSchematic.schx"); + Directory.CreateDirectory(Path.GetDirectoryName(defaultSchematicPath)!); + + using Stream resource = assembly.GetManifestResourceStream(resourceName)!; + using FileStream file = File.Create(defaultSchematicPath); + resource.CopyTo(file); + file.Close(); + + LoadSchematic(defaultSchematicPath); + } + public override byte[] SaveState() { PluginProgramParameters parameters = PluginProgramParameters.FromProcessor(SimulationProcessor); From ebb9ea269996c22cc828a2d60f5948e3a24f4b19 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:42:46 +0200 Subject: [PATCH 26/42] Add native Linux LV2 phaser plugin --- Native/LiveSPICE.LV2/.gitignore | 1 + Native/LiveSPICE.LV2/Makefile | 33 ++++ Native/LiveSPICE.LV2/README.md | 43 +++++ Native/LiveSPICE.LV2/livespice_mxr_phase90.c | 151 ++++++++++++++++++ .../LiveSPICE.LV2/livespice_mxr_phase90.ttl | 43 +++++ Native/LiveSPICE.LV2/manifest.ttl | 7 + 6 files changed, 278 insertions(+) create mode 100644 Native/LiveSPICE.LV2/.gitignore create mode 100644 Native/LiveSPICE.LV2/Makefile create mode 100644 Native/LiveSPICE.LV2/README.md create mode 100644 Native/LiveSPICE.LV2/livespice_mxr_phase90.c create mode 100644 Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl create mode 100644 Native/LiveSPICE.LV2/manifest.ttl diff --git a/Native/LiveSPICE.LV2/.gitignore b/Native/LiveSPICE.LV2/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/Native/LiveSPICE.LV2/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile new file mode 100644 index 00000000..1db0c1a6 --- /dev/null +++ b/Native/LiveSPICE.LV2/Makefile @@ -0,0 +1,33 @@ +CC ?= gcc +CFLAGS ?= -O3 -fPIC -Wall -Wextra -Werror +LDFLAGS ?= -shared +BUNDLE := build/LiveSPICE-MXR-Phase90.lv2 +PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so +TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl +PREFIX ?= $(HOME)/.lv2 + +.PHONY: all install clean test + +all: $(PLUGIN) $(TTL) + +$(BUNDLE): + mkdir -p $(BUNDLE) + +$(PLUGIN): livespice_mxr_phase90.c | $(BUNDLE) + $(CC) $(CFLAGS) $< $(LDFLAGS) -lm -o $@ + +$(BUNDLE)/manifest.ttl: manifest.ttl | $(BUNDLE) + cp $< $@ + +$(BUNDLE)/livespice_mxr_phase90.ttl: livespice_mxr_phase90.ttl | $(BUNDLE) + cp $< $@ + +install: all + mkdir -p $(PREFIX) + cp -r $(BUNDLE) $(PREFIX)/ + +test: all + LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/mxr-phase90 + +clean: + rm -rf build diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md new file mode 100644 index 00000000..03861ab6 --- /dev/null +++ b/Native/LiveSPICE.LV2/README.md @@ -0,0 +1,43 @@ +# LiveSPICE Native LV2 + +This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAPER that support LV2. + +The current plugin is a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. It builds to a real Linux ELF shared object, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. + +## Build + +```bash +make -C Native/LiveSPICE.LV2 clean all +``` + +## Test Discovery + +```bash +make -C Native/LiveSPICE.LV2 test +``` + +Expected URI: + +```text +https://livespice.org/plugins/mxr-phase90 +``` + +## Install + +```bash +make -C Native/LiveSPICE.LV2 install +``` + +This copies the bundle to: + +```text +~/.lv2/LiveSPICE-MXR-Phase90.lv2 +``` + +## Test With Carla + +```bash +carla-single lv2 https://livespice.org/plugins/mxr-phase90 +``` + +If the plugin loads successfully, Carla opens/runs the plugin host instead of immediately failing with a plugin description error. diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.c b/Native/LiveSPICE.LV2/livespice_mxr_phase90.c new file mode 100644 index 00000000..fc4e1a11 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.c @@ -0,0 +1,151 @@ +#include +#include +#include +#include + +#include + +#define LIVESPICE_MXR_PHASE90_URI "https://livespice.org/plugins/mxr-phase90" + +#define MIN_RATE_HZ 0.05f +#define MAX_RATE_HZ 8.0f +#define MIN_SWEEP_HZ 180.0f +#define MAX_SWEEP_HZ 1800.0f +#define STAGE_COUNT 4 + +typedef enum { + PORT_SPEED = 0, + PORT_TRIMMER = 1, + PORT_INPUT = 2, + PORT_OUTPUT = 3 +} PortIndex; + +typedef struct { + const float* speed; + const float* trimmer; + const float* input; + float* output; + double sample_rate; + float phase; + float x1[STAGE_COUNT]; + float y1[STAGE_COUNT]; +} LiveSpiceMxrPhase90; + +static float clampf(float value, float min, float max) +{ + if (value < min) + return min; + if (value > max) + return max; + return value; +} + +static LV2_Handle instantiate(const LV2_Descriptor* descriptor, double sample_rate, const char* bundle_path, const LV2_Feature* const* features) +{ + (void)descriptor; + (void)bundle_path; + (void)features; + + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)calloc(1, sizeof(LiveSpiceMxrPhase90)); + if (self != NULL) + self->sample_rate = sample_rate; + return (LV2_Handle)self; +} + +static void connect_port(LV2_Handle instance, uint32_t port, void* data) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + switch ((PortIndex)port) { + case PORT_SPEED: + self->speed = (const float*)data; + break; + case PORT_TRIMMER: + self->trimmer = (const float*)data; + break; + case PORT_INPUT: + self->input = (const float*)data; + break; + case PORT_OUTPUT: + self->output = (float*)data; + break; + } +} + +static void activate(LV2_Handle instance) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + self->phase = 0.0f; + memset(self->x1, 0, sizeof(self->x1)); + memset(self->y1, 0, sizeof(self->y1)); +} + +static float process_allpass(LiveSpiceMxrPhase90* self, float input, float coefficient, uint32_t stage) +{ + float output = coefficient * input + self->x1[stage] - coefficient * self->y1[stage]; + self->x1[stage] = input; + self->y1[stage] = output; + return output; +} + +static void run(LV2_Handle instance, uint32_t sample_count) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + if (self->input == NULL || self->output == NULL) + return; + + float speed = self->speed != NULL ? clampf(*self->speed, 0.0f, 1.0f) : 0.5f; + float trimmer = self->trimmer != NULL ? clampf(*self->trimmer, 0.0f, 1.0f) : 0.5f; + float rate = MIN_RATE_HZ * powf(MAX_RATE_HZ / MIN_RATE_HZ, speed); + float depth = 0.25f + trimmer * 0.75f; + float mix = 0.35f + trimmer * 0.45f; + float phase_increment = rate / (float)self->sample_rate; + + for (uint32_t sample = 0; sample < sample_count; sample++) { + float lfo = 0.5f + 0.5f * sinf(2.0f * (float)M_PI * self->phase); + float sweep = MIN_SWEEP_HZ + (MAX_SWEEP_HZ - MIN_SWEEP_HZ) * lfo * depth; + float tangent = tanf((float)M_PI * sweep / (float)self->sample_rate); + float coefficient = (1.0f - tangent) / (1.0f + tangent); + + float wet = self->input[sample]; + for (uint32_t stage = 0; stage < STAGE_COUNT; stage++) + wet = process_allpass(self, wet, coefficient, stage); + + self->output[sample] = self->input[sample] * (1.0f - mix) + wet * mix; + + self->phase += phase_increment; + if (self->phase >= 1.0f) + self->phase -= floorf(self->phase); + } +} + +static void deactivate(LV2_Handle instance) +{ + (void)instance; +} + +static void cleanup(LV2_Handle instance) +{ + free(instance); +} + +static const void* extension_data(const char* uri) +{ + (void)uri; + return NULL; +} + +static const LV2_Descriptor descriptor = { + LIVESPICE_MXR_PHASE90_URI, + instantiate, + connect_port, + activate, + run, + deactivate, + cleanup, + extension_data +}; + +LV2_SYMBOL_EXPORT const LV2_Descriptor* lv2_descriptor(uint32_t index) +{ + return index == 0 ? &descriptor : NULL; +} diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl new file mode 100644 index 00000000..85b04a2c --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl @@ -0,0 +1,43 @@ +@prefix doap: . +@prefix foaf: . +@prefix lv2: . +@prefix pprops: . +@prefix rdfs: . +@prefix units: . + + + a lv2:Plugin , lv2:PhaserPlugin ; + doap:name "LiveSPICE MXR Phase 90" ; + doap:license ; + doap:maintainer [ foaf:name "LiveSPICE" ] ; + lv2:optionalFeature lv2:hardRTCapable ; + lv2:port [ + a lv2:InputPort , lv2:ControlPort ; + lv2:index 0 ; + lv2:symbol "speed" ; + lv2:name "Speed" ; + lv2:default 0.5 ; + lv2:minimum 0.0 ; + lv2:maximum 1.0 ; + units:unit units:coef ; + pprops:logarithmic true + ] , [ + a lv2:InputPort , lv2:ControlPort ; + lv2:index 1 ; + lv2:symbol "trimmer" ; + lv2:name "Trimmer" ; + lv2:default 0.5 ; + lv2:minimum 0.0 ; + lv2:maximum 1.0 ; + units:unit units:coef + ] , [ + a lv2:InputPort , lv2:AudioPort ; + lv2:index 2 ; + lv2:symbol "input" ; + lv2:name "Input" + ] , [ + a lv2:OutputPort , lv2:AudioPort ; + lv2:index 3 ; + lv2:symbol "output" ; + lv2:name "Output" + ] . diff --git a/Native/LiveSPICE.LV2/manifest.ttl b/Native/LiveSPICE.LV2/manifest.ttl new file mode 100644 index 00000000..b779ef35 --- /dev/null +++ b/Native/LiveSPICE.LV2/manifest.ttl @@ -0,0 +1,7 @@ +@prefix lv2: . +@prefix rdfs: . + + + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . From fe96c940e17d7c923b955c39b8dd565fec853bd1 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:47:28 +0200 Subject: [PATCH 27/42] Add LV2 schematic state hook --- Native/LiveSPICE.LV2/README.md | 2 + Native/LiveSPICE.LV2/livespice_mxr_phase90.c | 76 ++++++++++++++++++- .../LiveSPICE.LV2/livespice_mxr_phase90.ttl | 4 + 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md index 03861ab6..4fab62e2 100644 --- a/Native/LiveSPICE.LV2/README.md +++ b/Native/LiveSPICE.LV2/README.md @@ -4,6 +4,8 @@ This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAP The current plugin is a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. It builds to a real Linux ELF shared object, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. +It also exposes an LV2 state interface with a `https://livespice.org/ns/plugin#schematicPath` property. That is the Linux-native state hook needed for the generic LiveSPICE model where hosts save/restore the selected `.schx` path, matching the Windows plugin's program-state design. The current DSP is still the built-in phaser; wiring arbitrary `.schx` simulation into the native plugin is the next engine bridge step. + ## Build ```bash diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.c b/Native/LiveSPICE.LV2/livespice_mxr_phase90.c index fc4e1a11..62a6fb7d 100644 --- a/Native/LiveSPICE.LV2/livespice_mxr_phase90.c +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.c @@ -4,8 +4,12 @@ #include #include +#include +#include +#include #define LIVESPICE_MXR_PHASE90_URI "https://livespice.org/plugins/mxr-phase90" +#define LIVESPICE__schematicPath "https://livespice.org/ns/plugin#schematicPath" #define MIN_RATE_HZ 0.05f #define MAX_RATE_HZ 8.0f @@ -29,6 +33,10 @@ typedef struct { float phase; float x1[STAGE_COUNT]; float y1[STAGE_COUNT]; + char* schematic_path; + LV2_URID_Map* map; + LV2_URID atom_path; + LV2_URID schematic_path_key; } LiveSpiceMxrPhase90; static float clampf(float value, float min, float max) @@ -44,11 +52,19 @@ static LV2_Handle instantiate(const LV2_Descriptor* descriptor, double sample_ra { (void)descriptor; (void)bundle_path; - (void)features; LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)calloc(1, sizeof(LiveSpiceMxrPhase90)); - if (self != NULL) + if (self != NULL) { self->sample_rate = sample_rate; + for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { + if (strcmp((*feature)->URI, LV2_URID__map) == 0) + self->map = (LV2_URID_Map*)(*feature)->data; + } + if (self->map != NULL) { + self->atom_path = self->map->map(self->map->handle, LV2_ATOM__Path); + self->schematic_path_key = self->map->map(self->map->handle, LIVESPICE__schematicPath); + } + } return (LV2_Handle)self; } @@ -125,12 +141,66 @@ static void deactivate(LV2_Handle instance) static void cleanup(LV2_Handle instance) { + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + free(self->schematic_path); free(instance); } +static LV2_State_Status save_state(LV2_Handle instance, LV2_State_Store_Function store, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + if (self->schematic_path_key == 0 || self->atom_path == 0 || self->schematic_path == NULL || self->schematic_path[0] == '\0') + return LV2_STATE_SUCCESS; + + return store( + handle, + self->schematic_path_key, + self->schematic_path, + strlen(self->schematic_path) + 1, + self->atom_path, + LV2_STATE_IS_POD); +} + +static LV2_State_Status restore_state(LV2_Handle instance, LV2_State_Retrieve_Function retrieve, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + if (self->schematic_path_key == 0) + return LV2_STATE_ERR_NO_FEATURE; + + size_t size = 0; + uint32_t type = 0; + uint32_t value_flags = 0; + const void* value = retrieve(handle, self->schematic_path_key, &size, &type, &value_flags); + if (value == NULL || size == 0) + return LV2_STATE_SUCCESS; + if (type != self->atom_path) + return LV2_STATE_ERR_BAD_TYPE; + + char* restored = (char*)calloc(size + 1, sizeof(char)); + if (restored == NULL) + return LV2_STATE_ERR_NO_SPACE; + memcpy(restored, value, size); + + free(self->schematic_path); + self->schematic_path = restored; + return LV2_STATE_SUCCESS; +} + +static const LV2_State_Interface state_interface = { + save_state, + restore_state +}; + static const void* extension_data(const char* uri) { - (void)uri; + if (strcmp(uri, LV2_STATE__interface) == 0) + return &state_interface; return NULL; } diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl index 85b04a2c..d6330b24 100644 --- a/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl @@ -3,14 +3,18 @@ @prefix lv2: . @prefix pprops: . @prefix rdfs: . +@prefix state: . @prefix units: . +@prefix livespice: . a lv2:Plugin , lv2:PhaserPlugin ; doap:name "LiveSPICE MXR Phase 90" ; doap:license ; doap:maintainer [ foaf:name "LiveSPICE" ] ; + lv2:extensionData state:interface ; lv2:optionalFeature lv2:hardRTCapable ; + livespice:schematicPath "" ; lv2:port [ a lv2:InputPort , lv2:ControlPort ; lv2:index 0 ; From d030240ca3a7b43109b8678495f4cdc11756a35f Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 08:57:44 +0200 Subject: [PATCH 28/42] Add generic Linux LV2 plugin shell --- .../SchematicCanvasInteractionTests.cs | 5 +- Native/LiveSPICE.LV2/Makefile | 19 +- Native/LiveSPICE.LV2/README.md | 13 +- Native/LiveSPICE.LV2/livespice_generic.c | 163 ++++++++++++++++++ Native/LiveSPICE.LV2/livespice_generic.ttl | 27 +++ Native/LiveSPICE.LV2/manifest.ttl | 5 + 6 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 Native/LiveSPICE.LV2/livespice_generic.c create mode 100644 Native/LiveSPICE.LV2/livespice_generic.ttl diff --git a/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs index 4ab210d6..38d97b41 100644 --- a/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs +++ b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs @@ -1,4 +1,5 @@ using Circuit; +using Avalonia.Threading; using LiveSPICE.Avalonia; using Xunit; @@ -165,7 +166,9 @@ private TestContext(SchematicDocument document, SchematicCanvas canvas) public static TestContext Load() { SchematicDocument document = SchematicDocument.Open(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); - SchematicCanvas canvas = new SchematicCanvas { Document = document }; + SchematicCanvas canvas = Dispatcher.UIThread.CheckAccess() + ? new SchematicCanvas { Document = document } + : Dispatcher.UIThread.Invoke(() => new SchematicCanvas { Document = document }); return new TestContext(document, canvas); } diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile index 1db0c1a6..8695be7f 100644 --- a/Native/LiveSPICE.LV2/Makefile +++ b/Native/LiveSPICE.LV2/Makefile @@ -1,19 +1,23 @@ CC ?= gcc CFLAGS ?= -O3 -fPIC -Wall -Wextra -Werror LDFLAGS ?= -shared -BUNDLE := build/LiveSPICE-MXR-Phase90.lv2 -PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so -TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl +BUNDLE := build/LiveSPICE.lv2 +MXR_PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so +GENERIC_PLUGIN := $(BUNDLE)/livespice_generic.so +TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl $(BUNDLE)/livespice_generic.ttl PREFIX ?= $(HOME)/.lv2 .PHONY: all install clean test -all: $(PLUGIN) $(TTL) +all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(TTL) $(BUNDLE): mkdir -p $(BUNDLE) -$(PLUGIN): livespice_mxr_phase90.c | $(BUNDLE) +$(MXR_PLUGIN): livespice_mxr_phase90.c | $(BUNDLE) + $(CC) $(CFLAGS) $< $(LDFLAGS) -lm -o $@ + +$(GENERIC_PLUGIN): livespice_generic.c | $(BUNDLE) $(CC) $(CFLAGS) $< $(LDFLAGS) -lm -o $@ $(BUNDLE)/manifest.ttl: manifest.ttl | $(BUNDLE) @@ -22,12 +26,17 @@ $(BUNDLE)/manifest.ttl: manifest.ttl | $(BUNDLE) $(BUNDLE)/livespice_mxr_phase90.ttl: livespice_mxr_phase90.ttl | $(BUNDLE) cp $< $@ +$(BUNDLE)/livespice_generic.ttl: livespice_generic.ttl | $(BUNDLE) + cp $< $@ + install: all mkdir -p $(PREFIX) + rm -rf $(PREFIX)/LiveSPICE-MXR-Phase90.lv2 cp -r $(BUNDLE) $(PREFIX)/ test: all LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/mxr-phase90 + LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/generic clean: rm -rf build diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md index 4fab62e2..9f1e9a6e 100644 --- a/Native/LiveSPICE.LV2/README.md +++ b/Native/LiveSPICE.LV2/README.md @@ -2,7 +2,12 @@ This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAPER that support LV2. -The current plugin is a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. It builds to a real Linux ELF shared object, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. +The bundle currently contains two native LV2 plugins: + +- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output and `schematicPath` state support. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. +- `LiveSPICE MXR Phase 90`: a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. + +Both build to real Linux ELF shared objects, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. It also exposes an LV2 state interface with a `https://livespice.org/ns/plugin#schematicPath` property. That is the Linux-native state hook needed for the generic LiveSPICE model where hosts save/restore the selected `.schx` path, matching the Windows plugin's program-state design. The current DSP is still the built-in phaser; wiring arbitrary `.schx` simulation into the native plugin is the next engine bridge step. @@ -18,9 +23,10 @@ make -C Native/LiveSPICE.LV2 clean all make -C Native/LiveSPICE.LV2 test ``` -Expected URI: +Expected URIs: ```text +https://livespice.org/plugins/generic https://livespice.org/plugins/mxr-phase90 ``` @@ -33,13 +39,14 @@ make -C Native/LiveSPICE.LV2 install This copies the bundle to: ```text -~/.lv2/LiveSPICE-MXR-Phase90.lv2 +~/.lv2/LiveSPICE.lv2 ``` ## Test With Carla ```bash carla-single lv2 https://livespice.org/plugins/mxr-phase90 +carla-single lv2 https://livespice.org/plugins/generic ``` If the plugin loads successfully, Carla opens/runs the plugin host instead of immediately failing with a plugin description error. diff --git a/Native/LiveSPICE.LV2/livespice_generic.c b/Native/LiveSPICE.LV2/livespice_generic.c new file mode 100644 index 00000000..bd3e8a76 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_generic.c @@ -0,0 +1,163 @@ +#include +#include +#include + +#include +#include +#include +#include + +#define LIVESPICE_GENERIC_URI "https://livespice.org/plugins/generic" +#define LIVESPICE__schematicPath "https://livespice.org/ns/plugin#schematicPath" + +typedef enum { + PORT_INPUT = 0, + PORT_OUTPUT = 1 +} PortIndex; + +typedef struct { + const float* input; + float* output; + char* schematic_path; + LV2_URID_Map* map; + LV2_URID atom_path; + LV2_URID schematic_path_key; +} LiveSpiceGeneric; + +static void map_features(LiveSpiceGeneric* self, const LV2_Feature* const* features) +{ + for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { + if (strcmp((*feature)->URI, LV2_URID__map) == 0) + self->map = (LV2_URID_Map*)(*feature)->data; + } + + if (self->map != NULL) { + self->atom_path = self->map->map(self->map->handle, LV2_ATOM__Path); + self->schematic_path_key = self->map->map(self->map->handle, LIVESPICE__schematicPath); + } +} + +static LV2_Handle instantiate(const LV2_Descriptor* descriptor, double sample_rate, const char* bundle_path, const LV2_Feature* const* features) +{ + (void)descriptor; + (void)sample_rate; + (void)bundle_path; + + LiveSpiceGeneric* self = (LiveSpiceGeneric*)calloc(1, sizeof(LiveSpiceGeneric)); + if (self != NULL) + map_features(self, features); + return (LV2_Handle)self; +} + +static void connect_port(LV2_Handle instance, uint32_t port, void* data) +{ + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + switch ((PortIndex)port) { + case PORT_INPUT: + self->input = (const float*)data; + break; + case PORT_OUTPUT: + self->output = (float*)data; + break; + } +} + +static void activate(LV2_Handle instance) +{ + (void)instance; +} + +static void run(LV2_Handle instance, uint32_t sample_count) +{ + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + if (self->input == NULL || self->output == NULL) + return; + + memcpy(self->output, self->input, sample_count * sizeof(float)); +} + +static void deactivate(LV2_Handle instance) +{ + (void)instance; +} + +static void cleanup(LV2_Handle instance) +{ + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + free(self->schematic_path); + free(instance); +} + +static LV2_State_Status save_state(LV2_Handle instance, LV2_State_Store_Function store, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + if (self->schematic_path_key == 0 || self->atom_path == 0 || self->schematic_path == NULL || self->schematic_path[0] == '\0') + return LV2_STATE_SUCCESS; + + return store( + handle, + self->schematic_path_key, + self->schematic_path, + strlen(self->schematic_path) + 1, + self->atom_path, + LV2_STATE_IS_POD); +} + +static LV2_State_Status restore_state(LV2_Handle instance, LV2_State_Retrieve_Function retrieve, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + if (self->schematic_path_key == 0) + return LV2_STATE_ERR_NO_FEATURE; + + size_t size = 0; + uint32_t type = 0; + uint32_t value_flags = 0; + const void* value = retrieve(handle, self->schematic_path_key, &size, &type, &value_flags); + if (value == NULL || size == 0) + return LV2_STATE_SUCCESS; + if (type != self->atom_path) + return LV2_STATE_ERR_BAD_TYPE; + + char* restored = (char*)calloc(size + 1, sizeof(char)); + if (restored == NULL) + return LV2_STATE_ERR_NO_SPACE; + memcpy(restored, value, size); + + free(self->schematic_path); + self->schematic_path = restored; + return LV2_STATE_SUCCESS; +} + +static const LV2_State_Interface state_interface = { + save_state, + restore_state +}; + +static const void* extension_data(const char* uri) +{ + if (strcmp(uri, LV2_STATE__interface) == 0) + return &state_interface; + return NULL; +} + +static const LV2_Descriptor descriptor = { + LIVESPICE_GENERIC_URI, + instantiate, + connect_port, + activate, + run, + deactivate, + cleanup, + extension_data +}; + +LV2_SYMBOL_EXPORT const LV2_Descriptor* lv2_descriptor(uint32_t index) +{ + return index == 0 ? &descriptor : NULL; +} diff --git a/Native/LiveSPICE.LV2/livespice_generic.ttl b/Native/LiveSPICE.LV2/livespice_generic.ttl new file mode 100644 index 00000000..121930e7 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_generic.ttl @@ -0,0 +1,27 @@ +@prefix doap: . +@prefix foaf: . +@prefix lv2: . +@prefix rdfs: . +@prefix state: . +@prefix livespice: . + + + a lv2:Plugin , lv2:SimulatorPlugin ; + doap:name "LiveSPICE Generic" ; + doap:license ; + doap:maintainer [ foaf:name "LiveSPICE" ] ; + lv2:extensionData state:interface ; + lv2:optionalFeature lv2:hardRTCapable ; + livespice:schematicPath "" ; + rdfs:comment "Generic LiveSPICE LV2 shell. It saves and restores a .schx schematic path and currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime." ; + lv2:port [ + a lv2:InputPort , lv2:AudioPort ; + lv2:index 0 ; + lv2:symbol "input" ; + lv2:name "Input" + ] , [ + a lv2:OutputPort , lv2:AudioPort ; + lv2:index 1 ; + lv2:symbol "output" ; + lv2:name "Output" + ] . diff --git a/Native/LiveSPICE.LV2/manifest.ttl b/Native/LiveSPICE.LV2/manifest.ttl index b779ef35..1c58975d 100644 --- a/Native/LiveSPICE.LV2/manifest.ttl +++ b/Native/LiveSPICE.LV2/manifest.ttl @@ -5,3 +5,8 @@ a lv2:Plugin ; lv2:binary ; rdfs:seeAlso . + + + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . From 1a6059ead0ed415de7ea0dd170343bce6d6d00ea Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 09:42:01 +0200 Subject: [PATCH 29/42] Add GTK LV2 schematic loader UI --- Native/LiveSPICE.LV2/Makefile | 8 +- Native/LiveSPICE.LV2/README.md | 2 +- Native/LiveSPICE.LV2/livespice_generic.c | 51 ++++- Native/LiveSPICE.LV2/livespice_generic.ttl | 18 +- Native/LiveSPICE.LV2/livespice_generic_ui.c | 208 ++++++++++++++++++++ 5 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 Native/LiveSPICE.LV2/livespice_generic_ui.c diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile index 8695be7f..d3f6944b 100644 --- a/Native/LiveSPICE.LV2/Makefile +++ b/Native/LiveSPICE.LV2/Makefile @@ -1,15 +1,18 @@ CC ?= gcc CFLAGS ?= -O3 -fPIC -Wall -Wextra -Werror LDFLAGS ?= -shared +GTK3_CFLAGS := $(shell pkg-config --cflags gtk+-3.0) +GTK3_LIBS := $(shell pkg-config --libs gtk+-3.0) BUNDLE := build/LiveSPICE.lv2 MXR_PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so GENERIC_PLUGIN := $(BUNDLE)/livespice_generic.so +GENERIC_UI := $(BUNDLE)/livespice_generic_ui.so TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl $(BUNDLE)/livespice_generic.ttl PREFIX ?= $(HOME)/.lv2 .PHONY: all install clean test -all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(TTL) +all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(GENERIC_UI) $(TTL) $(BUNDLE): mkdir -p $(BUNDLE) @@ -20,6 +23,9 @@ $(MXR_PLUGIN): livespice_mxr_phase90.c | $(BUNDLE) $(GENERIC_PLUGIN): livespice_generic.c | $(BUNDLE) $(CC) $(CFLAGS) $< $(LDFLAGS) -lm -o $@ +$(GENERIC_UI): livespice_generic_ui.c | $(BUNDLE) + $(CC) $(CFLAGS) $(GTK3_CFLAGS) $< $(LDFLAGS) $(GTK3_LIBS) -o $@ + $(BUNDLE)/manifest.ttl: manifest.ttl | $(BUNDLE) cp $< $@ diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md index 9f1e9a6e..a4e50085 100644 --- a/Native/LiveSPICE.LV2/README.md +++ b/Native/LiveSPICE.LV2/README.md @@ -4,7 +4,7 @@ This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAP The bundle currently contains two native LV2 plugins: -- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output and `schematicPath` state support. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. +- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output, `schematicPath` state support, and a GTK3 `Load Schematic` UI. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. - `LiveSPICE MXR Phase 90`: a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. Both build to real Linux ELF shared objects, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. diff --git a/Native/LiveSPICE.LV2/livespice_generic.c b/Native/LiveSPICE.LV2/livespice_generic.c index bd3e8a76..90621c82 100644 --- a/Native/LiveSPICE.LV2/livespice_generic.c +++ b/Native/LiveSPICE.LV2/livespice_generic.c @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -12,15 +13,18 @@ typedef enum { PORT_INPUT = 0, - PORT_OUTPUT = 1 + PORT_OUTPUT = 1, + PORT_CONTROL_EVENTS = 2 } PortIndex; typedef struct { const float* input; float* output; + const LV2_Atom_Sequence* control_events; char* schematic_path; LV2_URID_Map* map; LV2_URID atom_path; + LV2_URID atom_string; LV2_URID schematic_path_key; } LiveSpiceGeneric; @@ -33,10 +37,42 @@ static void map_features(LiveSpiceGeneric* self, const LV2_Feature* const* featu if (self->map != NULL) { self->atom_path = self->map->map(self->map->handle, LV2_ATOM__Path); + self->atom_string = self->map->map(self->map->handle, LV2_ATOM__String); self->schematic_path_key = self->map->map(self->map->handle, LIVESPICE__schematicPath); } } +static void set_schematic_path(LiveSpiceGeneric* self, const char* path, uint32_t size) +{ + if (path == NULL) + return; + + char* copy = (char*)calloc((size_t)size + 1, sizeof(char)); + if (copy == NULL) + return; + + if (size > 0) + memcpy(copy, path, size); + copy[size] = '\0'; + + free(self->schematic_path); + self->schematic_path = copy; +} + +static void read_control_events(LiveSpiceGeneric* self) +{ + if (self->control_events == NULL || self->control_events->atom.type == 0) + return; + + LV2_ATOM_SEQUENCE_FOREACH(self->control_events, event) { + if (event->body.type != self->atom_path && event->body.type != self->atom_string) + continue; + + const char* path = (const char*)LV2_ATOM_BODY(&event->body); + set_schematic_path(self, path, event->body.size); + } +} + static LV2_Handle instantiate(const LV2_Descriptor* descriptor, double sample_rate, const char* bundle_path, const LV2_Feature* const* features) { (void)descriptor; @@ -59,6 +95,9 @@ static void connect_port(LV2_Handle instance, uint32_t port, void* data) case PORT_OUTPUT: self->output = (float*)data; break; + case PORT_CONTROL_EVENTS: + self->control_events = (const LV2_Atom_Sequence*)data; + break; } } @@ -70,6 +109,8 @@ static void activate(LV2_Handle instance) static void run(LV2_Handle instance, uint32_t sample_count) { LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + read_control_events(self); + if (self->input == NULL || self->output == NULL) return; @@ -124,13 +165,7 @@ static LV2_State_Status restore_state(LV2_Handle instance, LV2_State_Retrieve_Fu if (type != self->atom_path) return LV2_STATE_ERR_BAD_TYPE; - char* restored = (char*)calloc(size + 1, sizeof(char)); - if (restored == NULL) - return LV2_STATE_ERR_NO_SPACE; - memcpy(restored, value, size); - - free(self->schematic_path); - self->schematic_path = restored; + set_schematic_path(self, (const char*)value, (uint32_t)size); return LV2_STATE_SUCCESS; } diff --git a/Native/LiveSPICE.LV2/livespice_generic.ttl b/Native/LiveSPICE.LV2/livespice_generic.ttl index 121930e7..c03b7f46 100644 --- a/Native/LiveSPICE.LV2/livespice_generic.ttl +++ b/Native/LiveSPICE.LV2/livespice_generic.ttl @@ -1,8 +1,11 @@ @prefix doap: . @prefix foaf: . +@prefix atom: . @prefix lv2: . @prefix rdfs: . @prefix state: . +@prefix ui: . +@prefix urid: . @prefix livespice: . @@ -12,8 +15,10 @@ doap:maintainer [ foaf:name "LiveSPICE" ] ; lv2:extensionData state:interface ; lv2:optionalFeature lv2:hardRTCapable ; + lv2:requiredFeature urid:map ; + ui:ui ; livespice:schematicPath "" ; - rdfs:comment "Generic LiveSPICE LV2 shell. It saves and restores a .schx schematic path and currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime." ; + rdfs:comment "Generic LiveSPICE LV2 shell. It provides a GTK3 Load Schematic UI, saves/restores a .schx schematic path, and currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime." ; lv2:port [ a lv2:InputPort , lv2:AudioPort ; lv2:index 0 ; @@ -24,4 +29,15 @@ lv2:index 1 ; lv2:symbol "output" ; lv2:name "Output" + ] , [ + a lv2:InputPort , atom:AtomPort ; + atom:bufferType atom:Sequence ; + atom:supports atom:Path ; + lv2:index 2 ; + lv2:symbol "control_events" ; + lv2:name "Control Events" ] . + + + a ui:Gtk3UI ; + ui:binary . diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c new file mode 100644 index 00000000..f2b09a37 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -0,0 +1,208 @@ +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#define LIVESPICE_GENERIC_URI "https://livespice.org/plugins/generic" +#define LIVESPICE_GENERIC_UI_URI "https://livespice.org/plugins/generic#gtk3-ui" + +typedef enum { + PORT_INPUT = 0, + PORT_OUTPUT = 1, + PORT_CONTROL_EVENTS = 2 +} PortIndex; + +typedef struct { + LV2UI_Write_Function write; + LV2UI_Controller controller; + LV2_URID_Map* map; + LV2_URID atom_event_transfer; + LV2_Atom_Forge forge; + GtkWidget* root; + GtkWidget* path_label; + char* schematic_path; +} LiveSpiceGenericUi; + +static void gtk_init_once(void) +{ + static bool initialized = false; + if (!initialized) { + int argc = 0; + char** argv = NULL; + gtk_init(&argc, &argv); + initialized = true; + } +} + +static void set_label_path(LiveSpiceGenericUi* self, const char* path) +{ + gtk_label_set_text(GTK_LABEL(self->path_label), path != NULL && path[0] != '\0' ? path : "No schematic loaded"); +} + +static void send_schematic_path(LiveSpiceGenericUi* self, const char* path) +{ + if (self->map == NULL || path == NULL) + return; + + uint8_t buffer[4096]; + lv2_atom_forge_set_buffer(&self->forge, buffer, sizeof(buffer)); + LV2_Atom_Forge_Ref ref = lv2_atom_forge_path(&self->forge, path, (uint32_t)strlen(path) + 1); + if (ref == 0) + return; + + LV2_Atom* atom = lv2_atom_forge_deref(&self->forge, ref); + self->write(self->controller, PORT_CONTROL_EVENTS, lv2_atom_total_size(atom), self->atom_event_transfer, atom); +} + +static void set_schematic_path(LiveSpiceGenericUi* self, const char* path) +{ + char* copy = strdup(path); + if (copy == NULL) + return; + + free(self->schematic_path); + self->schematic_path = copy; + set_label_path(self, copy); + send_schematic_path(self, copy); +} + +static void load_schematic_clicked(GtkButton* button, gpointer data) +{ + (void)button; + LiveSpiceGenericUi* self = (LiveSpiceGenericUi*)data; + + GtkWidget* dialog = gtk_file_chooser_dialog_new( + "Load LiveSPICE Schematic", + NULL, + GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", + GTK_RESPONSE_CANCEL, + "_Open", + GTK_RESPONSE_ACCEPT, + NULL); + + GtkFileFilter* filter = gtk_file_filter_new(); + gtk_file_filter_set_name(filter, "LiveSPICE schematics"); + gtk_file_filter_add_pattern(filter, "*.schx"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + char* filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + if (filename != NULL) { + set_schematic_path(self, filename); + g_free(filename); + } + } + + gtk_widget_destroy(dialog); +} + +static void clear_schematic_clicked(GtkButton* button, gpointer data) +{ + (void)button; + set_schematic_path((LiveSpiceGenericUi*)data, ""); +} + +static LV2UI_Handle instantiate( + const LV2UI_Descriptor* descriptor, + const char* plugin_uri, + const char* bundle_path, + LV2UI_Write_Function write_function, + LV2UI_Controller controller, + LV2UI_Widget* widget, + const LV2_Feature* const* features) +{ + (void)descriptor; + (void)bundle_path; + + if (strcmp(plugin_uri, LIVESPICE_GENERIC_URI) != 0) + return NULL; + + LiveSpiceGenericUi* self = (LiveSpiceGenericUi*)calloc(1, sizeof(LiveSpiceGenericUi)); + if (self == NULL) + return NULL; + + self->write = write_function; + self->controller = controller; + + for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { + if (strcmp((*feature)->URI, LV2_URID__map) == 0) + self->map = (LV2_URID_Map*)(*feature)->data; + } + + if (self->map != NULL) { + self->atom_event_transfer = self->map->map(self->map->handle, LV2_ATOM__eventTransfer); + lv2_atom_forge_init(&self->forge, self->map); + } + + gtk_init_once(); + + self->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_container_set_border_width(GTK_CONTAINER(self->root), 10); + + GtkWidget* title = gtk_label_new("LiveSPICE Generic"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(self->root), title, false, false, 0); + + self->path_label = gtk_label_new("No schematic loaded"); + gtk_label_set_ellipsize(GTK_LABEL(self->path_label), PANGO_ELLIPSIZE_MIDDLE); + gtk_widget_set_halign(self->path_label, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(self->root), self->path_label, false, false, 0); + + GtkWidget* controls = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* load_button = gtk_button_new_with_label("Load Schematic"); + GtkWidget* clear_button = gtk_button_new_with_label("Clear"); + gtk_box_pack_start(GTK_BOX(controls), load_button, false, false, 0); + gtk_box_pack_start(GTK_BOX(controls), clear_button, false, false, 0); + gtk_box_pack_start(GTK_BOX(self->root), controls, false, false, 0); + + g_signal_connect(load_button, "clicked", G_CALLBACK(load_schematic_clicked), self); + g_signal_connect(clear_button, "clicked", G_CALLBACK(clear_schematic_clicked), self); + + gtk_widget_show_all(self->root); + *widget = self->root; + return (LV2UI_Handle)self; +} + +static void cleanup(LV2UI_Handle ui) +{ + LiveSpiceGenericUi* self = (LiveSpiceGenericUi*)ui; + free(self->schematic_path); + free(self); +} + +static void port_event(LV2UI_Handle ui, uint32_t port_index, uint32_t buffer_size, uint32_t format, const void* buffer) +{ + (void)ui; + (void)port_index; + (void)buffer_size; + (void)format; + (void)buffer; +} + +static const void* extension_data(const char* uri) +{ + (void)uri; + return NULL; +} + +static const LV2UI_Descriptor descriptor = { + LIVESPICE_GENERIC_UI_URI, + instantiate, + cleanup, + port_event, + extension_data +}; + +LV2_SYMBOL_EXPORT const LV2UI_Descriptor* lv2ui_descriptor(uint32_t index) +{ + return index == 0 ? &descriptor : NULL; +} \ No newline at end of file From d09240cdc097b900c0da636633deb1ea1632e99b Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 09:45:55 +0200 Subject: [PATCH 30/42] Show schematic controls in LV2 UI --- Native/LiveSPICE.LV2/README.md | 2 +- Native/LiveSPICE.LV2/livespice_generic_ui.c | 228 ++++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md index a4e50085..2edb9aa4 100644 --- a/Native/LiveSPICE.LV2/README.md +++ b/Native/LiveSPICE.LV2/README.md @@ -4,7 +4,7 @@ This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAP The bundle currently contains two native LV2 plugins: -- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output, `schematicPath` state support, and a GTK3 `Load Schematic` UI. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. +- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output, `schematicPath` state support, and a GTK3 `Load Schematic` UI. Loading a `.schx` updates the UI with discovered potentiometer and switch controls, and `Clear` resets the loaded schematic and control panel. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. - `LiveSPICE MXR Phase 90`: a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. Both build to real Linux ELF shared objects, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index f2b09a37..f98cc740 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -20,6 +20,14 @@ typedef enum { PORT_CONTROL_EVENTS = 2 } PortIndex; +typedef struct { + char* name; + char* group; + char* type; + double value; + int positions; +} SchematicControl; + typedef struct { LV2UI_Write_Function write; LV2UI_Controller controller; @@ -28,9 +36,149 @@ typedef struct { LV2_Atom_Forge forge; GtkWidget* root; GtkWidget* path_label; + GtkWidget* controls_box; + GtkWidget* empty_controls_label; char* schematic_path; } LiveSpiceGenericUi; +static char* duplicate_range(const char* start, size_t length) +{ + char* copy = (char*)calloc(length + 1, sizeof(char)); + if (copy == NULL) + return NULL; + + memcpy(copy, start, length); + return copy; +} + +static char* read_attribute(const char* element, const char* name) +{ + char pattern[64]; + snprintf(pattern, sizeof(pattern), "%s=\"", name); + + const char* value = strstr(element, pattern); + if (value == NULL) + return NULL; + + value += strlen(pattern); + const char* end = strchr(value, '"'); + if (end == NULL) + return NULL; + + return duplicate_range(value, (size_t)(end - value)); +} + +static bool component_is_type(const char* type, const char* component_name) +{ + return type != NULL && strstr(type, component_name) != NULL; +} + +static int switch_positions_from_type(const char* type) +{ + if (component_is_type(type, "SPDT")) + return 2; + if (component_is_type(type, "SP3T")) + return 3; + if (component_is_type(type, "SP4T")) + return 4; + if (component_is_type(type, "SP5T")) + return 5; + return 2; +} + +static char* control_display_name(const SchematicControl* control) +{ + if (control->group != NULL && control->group[0] != '\0') + return strdup(control->group); + if (control->name != NULL && control->name[0] != '\0') + return strdup(control->name); + return strdup(control->type != NULL ? control->type : "Control"); +} + +static void free_schematic_control(SchematicControl* control) +{ + free(control->name); + free(control->group); + free(control->type); +} + +static bool add_unique_control(GArray* controls, SchematicControl* control) +{ + char* display_name = control_display_name(control); + if (display_name == NULL) + return false; + + for (guint i = 0; i < controls->len; i++) { + SchematicControl* existing = &g_array_index(controls, SchematicControl, i); + char* existing_name = control_display_name(existing); + bool duplicate = existing_name != NULL && strcmp(existing_name, display_name) == 0 && strcmp(existing->type, control->type) == 0; + free(existing_name); + if (duplicate) { + free(display_name); + free_schematic_control(control); + return true; + } + } + + free(display_name); + g_array_append_val(controls, *control); + return true; +} + +static GArray* read_schematic_controls(const char* path) +{ + GArray* controls = g_array_new(false, false, sizeof(SchematicControl)); + if (path == NULL || path[0] == '\0') + return controls; + + gchar* contents = NULL; + gsize length = 0; + if (!g_file_get_contents(path, &contents, &length, NULL)) + return controls; + + const char* cursor = contents; + while ((cursor = strstr(cursor, "'); + if (end == NULL) + break; + + char* element = duplicate_range(cursor, (size_t)(end - cursor)); + if (element == NULL) + break; + + char* component_type = read_attribute(element, "_Type"); + if (component_is_type(component_type, "Potentiometer") || component_is_type(component_type, "VariableResistor")) { + SchematicControl control = { 0 }; + control.name = read_attribute(element, "Name"); + control.group = read_attribute(element, "Group"); + control.type = strdup("pot"); + char* wipe = read_attribute(element, "Wipe"); + control.value = wipe != NULL ? g_ascii_strtod(wipe, NULL) : 0.5; + control.positions = 0; + free(wipe); + add_unique_control(controls, &control); + } + else if (component_is_type(component_type, "SPDT") || component_is_type(component_type, "SP3T") || component_is_type(component_type, "SP4T") || component_is_type(component_type, "SP5T")) { + SchematicControl control = { 0 }; + control.name = read_attribute(element, "Name"); + control.group = read_attribute(element, "Group"); + control.type = strdup("switch"); + char* position = read_attribute(element, "Position"); + control.value = position != NULL ? g_ascii_strtod(position, NULL) : 0; + control.positions = switch_positions_from_type(component_type); + free(position); + add_unique_control(controls, &control); + } + + free(component_type); + free(element); + cursor = end + 1; + } + + g_free(contents); + return controls; +} + static void gtk_init_once(void) { static bool initialized = false; @@ -47,6 +195,77 @@ static void set_label_path(LiveSpiceGenericUi* self, const char* path) gtk_label_set_text(GTK_LABEL(self->path_label), path != NULL && path[0] != '\0' ? path : "No schematic loaded"); } +static void clear_control_panel(LiveSpiceGenericUi* self) +{ + GList* children = gtk_container_get_children(GTK_CONTAINER(self->controls_box)); + for (GList* child = children; child != NULL; child = child->next) + gtk_widget_destroy(GTK_WIDGET(child->data)); + g_list_free(children); +} + +static GtkWidget* create_control_label(const char* text) +{ + GtkWidget* label = gtk_label_new(text); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + return label; +} + +static GtkWidget* create_pot_control(const SchematicControl* control) +{ + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + char* name = control_display_name(control); + gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Pot"), false, false, 0); + + GtkWidget* slider = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, 0, 1, 0.01); + gtk_range_set_value(GTK_RANGE(slider), control->value); + gtk_widget_set_size_request(slider, 120, -1); + gtk_box_pack_start(GTK_BOX(box), slider, false, false, 0); + free(name); + return box; +} + +static GtkWidget* create_switch_control(const SchematicControl* control) +{ + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + char* name = control_display_name(control); + gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Switch"), false, false, 0); + + GtkWidget* combo = gtk_combo_box_text_new(); + for (int i = 0; i < control->positions; i++) { + char text[16]; + snprintf(text, sizeof(text), "%d", i); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), text); + } + gtk_combo_box_set_active(GTK_COMBO_BOX(combo), (int)control->value); + gtk_box_pack_start(GTK_BOX(box), combo, false, false, 0); + free(name); + return box; +} + +static void rebuild_control_panel(LiveSpiceGenericUi* self, const char* path) +{ + clear_control_panel(self); + + GArray* controls = read_schematic_controls(path); + if (controls->len == 0) { + self->empty_controls_label = create_control_label(path != NULL && path[0] != '\0' ? "No schematic controls found" : "Load a schematic to show controls"); + gtk_box_pack_start(GTK_BOX(self->controls_box), self->empty_controls_label, false, false, 0); + } + else { + for (guint i = 0; i < controls->len; i++) { + SchematicControl* control = &g_array_index(controls, SchematicControl, i); + GtkWidget* widget = strcmp(control->type, "switch") == 0 ? create_switch_control(control) : create_pot_control(control); + gtk_box_pack_start(GTK_BOX(self->controls_box), widget, false, false, 0); + } + } + + for (guint i = 0; i < controls->len; i++) + free_schematic_control(&g_array_index(controls, SchematicControl, i)); + g_array_free(controls, true); + gtk_widget_show_all(self->controls_box); +} + static void send_schematic_path(LiveSpiceGenericUi* self, const char* path) { if (self->map == NULL || path == NULL) @@ -71,6 +290,7 @@ static void set_schematic_path(LiveSpiceGenericUi* self, const char* path) free(self->schematic_path); self->schematic_path = copy; set_label_path(self, copy); + rebuild_control_panel(self, copy); send_schematic_path(self, copy); } @@ -164,6 +384,14 @@ static LV2UI_Handle instantiate( gtk_box_pack_start(GTK_BOX(controls), clear_button, false, false, 0); gtk_box_pack_start(GTK_BOX(self->root), controls, false, false, 0); + GtkWidget* scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_NEVER); + gtk_widget_set_size_request(scroll, 320, 96); + self->controls_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_container_add(GTK_CONTAINER(scroll), self->controls_box); + gtk_box_pack_start(GTK_BOX(self->root), scroll, true, true, 0); + rebuild_control_panel(self, ""); + g_signal_connect(load_button, "clicked", G_CALLBACK(load_schematic_clicked), self); g_signal_connect(clear_button, "clicked", G_CALLBACK(clear_schematic_clicked), self); From 3a9dc7ec937efc4a93e537845e5d2d41885b068c Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 09:57:54 +0200 Subject: [PATCH 31/42] Style LV2 UI like Windows VST --- Native/LiveSPICE.LV2/Makefile | 6 +- Native/LiveSPICE.LV2/README.md | 2 +- Native/LiveSPICE.LV2/livespice_generic_ui.c | 235 +++++++++++++++++++- 3 files changed, 233 insertions(+), 10 deletions(-) diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile index d3f6944b..38d0a832 100644 --- a/Native/LiveSPICE.LV2/Makefile +++ b/Native/LiveSPICE.LV2/Makefile @@ -8,11 +8,12 @@ MXR_PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so GENERIC_PLUGIN := $(BUNDLE)/livespice_generic.so GENERIC_UI := $(BUNDLE)/livespice_generic_ui.so TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl $(BUNDLE)/livespice_generic.ttl +ASSETS := $(BUNDLE)/MetalAlpha.png PREFIX ?= $(HOME)/.lv2 .PHONY: all install clean test -all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(GENERIC_UI) $(TTL) +all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(GENERIC_UI) $(TTL) $(ASSETS) $(BUNDLE): mkdir -p $(BUNDLE) @@ -35,6 +36,9 @@ $(BUNDLE)/livespice_mxr_phase90.ttl: livespice_mxr_phase90.ttl | $(BUNDLE) $(BUNDLE)/livespice_generic.ttl: livespice_generic.ttl | $(BUNDLE) cp $< $@ +$(BUNDLE)/MetalAlpha.png: ../../LiveSPICEVst/Images/MetalAlpha.png | $(BUNDLE) + cp $< $@ + install: all mkdir -p $(PREFIX) rm -rf $(PREFIX)/LiveSPICE-MXR-Phase90.lv2 diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md index 2edb9aa4..d5066ac0 100644 --- a/Native/LiveSPICE.LV2/README.md +++ b/Native/LiveSPICE.LV2/README.md @@ -4,7 +4,7 @@ This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAP The bundle currently contains two native LV2 plugins: -- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output, `schematicPath` state support, and a GTK3 `Load Schematic` UI. Loading a `.schx` updates the UI with discovered potentiometer and switch controls, and `Clear` resets the loaded schematic and control panel. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. +- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output, `schematicPath` state support, and a GTK3 `Load Schematic` UI styled after the Windows VST metal panel. Loading a `.schx` updates the UI with discovered potentiometer and switch controls, and `Clear` resets the loaded schematic and control panel. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. - `LiveSPICE MXR Phase 90`: a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. Both build to real Linux ELF shared objects, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index f98cc740..6bda43e2 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -28,9 +29,18 @@ typedef struct { int positions; } SchematicControl; +typedef struct { + GtkWidget* area; + double value; + bool dragging; + double drag_y; + double drag_value; +} KnobControl; + typedef struct { LV2UI_Write_Function write; LV2UI_Controller controller; + LV2UI_Resize* resize; LV2_URID_Map* map; LV2_URID atom_event_transfer; LV2_Atom_Forge forge; @@ -39,8 +49,33 @@ typedef struct { GtkWidget* controls_box; GtkWidget* empty_controls_label; char* schematic_path; + int control_count; } LiveSpiceGenericUi; +static const char* css_template = + "#livespice-root {" + " background-color: #666;" + " background-image: linear-gradient(145deg, rgba(255,255,255,0.55), rgba(255,255,255,0.08) 28%, rgba(0,0,0,0.12) 55%, rgba(255,255,255,0.20)), url('%s');" + " background-repeat: repeat;" + " border-radius: 8px;" + " border: 1px solid rgba(255,255,255,0.45);" + " box-shadow: inset 0 1px rgba(255,255,255,0.85), inset 0 -1px rgba(0,0,0,0.35);" + "}" + "#livespice-title { color: #111; font-weight: 800; font-size: 15px; text-shadow: 0 1px rgba(255,255,255,0.45); }" + "#livespice-path { color: #202020; font-weight: 700; text-shadow: 0 1px rgba(255,255,255,0.35); }" + ".livespice-button {" + " color: #111; font-weight: 700; padding: 4px 12px; border-radius: 4px;" + " background-image: linear-gradient(#f4f4f4, #b8b8b8 48%, #8f8f8f 52%, #d5d5d5);" + " border: 1px solid #4b4b4b; box-shadow: inset 0 1px rgba(255,255,255,0.9), 0 1px rgba(0,0,0,0.25);" + "}" + ".livespice-control-card {" + " background-color: rgba(235,235,235,0.42); border: 1px solid rgba(35,35,35,0.28);" + " border-radius: 5px; padding: 6px; box-shadow: inset 0 1px rgba(255,255,255,0.45);" + " min-width: 86px; min-height: 88px;" + "}" + ".livespice-control-label { color: #101010; font-weight: 800; font-size: 11px; text-shadow: 0 1px rgba(255,255,255,0.45); }" + ".livespice-combo { color: #111; font-weight: 700; }"; + static char* duplicate_range(const char* start, size_t length) { char* copy = (char*)calloc(length + 1, sizeof(char)); @@ -190,6 +225,35 @@ static void gtk_init_once(void) } } +static void add_css_class(GtkWidget* widget, const char* class_name) +{ + GtkStyleContext* context = gtk_widget_get_style_context(widget); + gtk_style_context_add_class(context, class_name); +} + +static void install_css(const char* bundle_path) +{ + char* texture_path = g_build_filename(bundle_path != NULL ? bundle_path : "", "MetalAlpha.png", NULL); + char* texture_uri = g_filename_to_uri(texture_path, NULL, NULL); + char* css = g_strdup_printf(css_template, texture_uri != NULL ? texture_uri : ""); + GtkCssProvider* provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(provider, css, -1, NULL); + gtk_style_context_add_provider_for_screen(gdk_screen_get_default(), GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(provider); + g_free(css); + g_free(texture_uri); + g_free(texture_path); +} + +static double clamp_unit(double value) +{ + if (value < 0) + return 0; + if (value > 1) + return 1; + return value; +} + static void set_label_path(LiveSpiceGenericUi* self, const char* path) { gtk_label_set_text(GTK_LABEL(self->path_label), path != NULL && path[0] != '\0' ? path : "No schematic loaded"); @@ -201,26 +265,168 @@ static void clear_control_panel(LiveSpiceGenericUi* self) for (GList* child = children; child != NULL; child = child->next) gtk_widget_destroy(GTK_WIDGET(child->data)); g_list_free(children); + self->control_count = 0; +} + +static void request_ui_size(LiveSpiceGenericUi* self) +{ + if (self->resize == NULL || self->resize->ui_resize == NULL) + return; + + int width = 360 + (self->control_count * 104); + if (width < 420) + width = 420; + if (width > 860) + width = 860; + + int height = self->control_count > 0 ? 205 : 150; + self->resize->ui_resize(self->resize->handle, width, height); } static GtkWidget* create_control_label(const char* text) { GtkWidget* label = gtk_label_new(text); + add_css_class(label, "livespice-control-label"); gtk_widget_set_halign(label, GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); return label; } +static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) +{ + (void)widget; + KnobControl* knob = (KnobControl*)data; + GtkAllocation allocation; + gtk_widget_get_allocation(knob->area, &allocation); + + double size = allocation.width < allocation.height ? allocation.width : allocation.height; + double center = size / 2.0; + double radius = (size / 2.0) - 5.0; + double value = clamp_unit(knob->value); + + cairo_pattern_t* shadow = cairo_pattern_create_radial(center + 3, center + 5, radius * 0.15, center + 3, center + 5, radius); + cairo_pattern_add_color_stop_rgba(shadow, 0, 0, 0, 0, 0.20); + cairo_pattern_add_color_stop_rgba(shadow, 1, 0, 0, 0, 0.00); + cairo_set_source(cr, shadow); + cairo_arc(cr, center + 3, center + 5, radius, 0, 2 * G_PI); + cairo_fill(cr); + cairo_pattern_destroy(shadow); + + cairo_pattern_t* body = cairo_pattern_create_radial(center - radius * 0.35, center - radius * 0.45, radius * 0.1, center, center, radius); + cairo_pattern_add_color_stop_rgb(body, 0, 0.96, 0.96, 0.93); + cairo_pattern_add_color_stop_rgb(body, 0.42, 0.58, 0.58, 0.56); + cairo_pattern_add_color_stop_rgb(body, 1, 0.14, 0.14, 0.14); + cairo_set_source(cr, body); + cairo_arc(cr, center, center, radius, 0, 2 * G_PI); + cairo_fill_preserve(cr); + cairo_pattern_destroy(body); + + cairo_set_source_rgb(cr, 0.07, 0.07, 0.07); + cairo_set_line_width(cr, 1.2); + cairo_stroke(cr); + + cairo_pattern_t* cap = cairo_pattern_create_linear(center, center - radius, center, center + radius); + cairo_pattern_add_color_stop_rgba(cap, 0, 1, 1, 1, 0.48); + cairo_pattern_add_color_stop_rgba(cap, 0.45, 1, 1, 1, 0.06); + cairo_pattern_add_color_stop_rgba(cap, 1, 0, 0, 0, 0.22); + cairo_set_source(cr, cap); + cairo_arc(cr, center, center, radius - 3, 0, 2 * G_PI); + cairo_fill(cr); + cairo_pattern_destroy(cap); + + double angle = (-135.0 + (270.0 * value)) * G_PI / 180.0; + double indicator_inner = radius * 0.18; + double indicator_outer = radius * 0.78; + cairo_set_source_rgb(cr, 0.02, 0.02, 0.02); + cairo_set_line_width(cr, 4.0); + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_move_to(cr, center + cos(angle) * indicator_inner, center + sin(angle) * indicator_inner); + cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); + cairo_stroke(cr); + + cairo_set_source_rgba(cr, 1, 1, 1, 0.85); + cairo_set_line_width(cr, 1.2); + cairo_move_to(cr, center + cos(angle) * indicator_inner, center + sin(angle) * indicator_inner); + cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); + cairo_stroke(cr); + + return false; +} + +static gboolean knob_button_press(GtkWidget* widget, GdkEventButton* event, gpointer data) +{ + (void)widget; + KnobControl* knob = (KnobControl*)data; + if (event->button != GDK_BUTTON_PRIMARY) + return false; + + knob->dragging = true; + knob->drag_y = event->y_root; + knob->drag_value = knob->value; + return true; +} + +static gboolean knob_button_release(GtkWidget* widget, GdkEventButton* event, gpointer data) +{ + (void)widget; + (void)event; + ((KnobControl*)data)->dragging = false; + return true; +} + +static gboolean knob_motion(GtkWidget* widget, GdkEventMotion* event, gpointer data) +{ + KnobControl* knob = (KnobControl*)data; + if (!knob->dragging) + return false; + + knob->value = clamp_unit(knob->drag_value + ((knob->drag_y - event->y_root) / 120.0)); + gtk_widget_queue_draw(widget); + return true; +} + +static gboolean knob_scroll(GtkWidget* widget, GdkEventScroll* event, gpointer data) +{ + KnobControl* knob = (KnobControl*)data; + double delta = event->direction == GDK_SCROLL_UP ? 0.025 : -0.025; + knob->value = clamp_unit(knob->value + delta); + gtk_widget_queue_draw(widget); + return true; +} + +static void free_knob_control(gpointer data) +{ + free(data); +} + +static GtkWidget* create_knob(double value) +{ + KnobControl* knob = (KnobControl*)calloc(1, sizeof(KnobControl)); + knob->value = clamp_unit(value); + knob->area = gtk_drawing_area_new(); + gtk_widget_set_size_request(knob->area, 68, 68); + gtk_widget_add_events(knob->area, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); + g_signal_connect(knob->area, "draw", G_CALLBACK(draw_knob), knob); + g_signal_connect(knob->area, "button-press-event", G_CALLBACK(knob_button_press), knob); + g_signal_connect(knob->area, "button-release-event", G_CALLBACK(knob_button_release), knob); + g_signal_connect(knob->area, "motion-notify-event", G_CALLBACK(knob_motion), knob); + g_signal_connect(knob->area, "scroll-event", G_CALLBACK(knob_scroll), knob); + g_object_set_data_full(G_OBJECT(knob->area), "livespice-knob", knob, free_knob_control); + return knob->area; +} + static GtkWidget* create_pot_control(const SchematicControl* control) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + add_css_class(box, "livespice-control-card"); + gtk_widget_set_size_request(box, 92, 92); + gtk_widget_set_valign(box, GTK_ALIGN_START); char* name = control_display_name(control); gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Pot"), false, false, 0); - GtkWidget* slider = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, 0, 1, 0.01); - gtk_range_set_value(GTK_RANGE(slider), control->value); - gtk_widget_set_size_request(slider, 120, -1); - gtk_box_pack_start(GTK_BOX(box), slider, false, false, 0); + GtkWidget* knob = create_knob(control->value); + gtk_widget_set_halign(knob, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(box), knob, false, false, 0); free(name); return box; } @@ -228,6 +434,9 @@ static GtkWidget* create_pot_control(const SchematicControl* control) static GtkWidget* create_switch_control(const SchematicControl* control) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + add_css_class(box, "livespice-control-card"); + gtk_widget_set_size_request(box, 92, 92); + gtk_widget_set_valign(box, GTK_ALIGN_START); char* name = control_display_name(control); gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Switch"), false, false, 0); @@ -238,6 +447,7 @@ static GtkWidget* create_switch_control(const SchematicControl* control) gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), text); } gtk_combo_box_set_active(GTK_COMBO_BOX(combo), (int)control->value); + add_css_class(combo, "livespice-combo"); gtk_box_pack_start(GTK_BOX(box), combo, false, false, 0); free(name); return box; @@ -259,11 +469,13 @@ static void rebuild_control_panel(LiveSpiceGenericUi* self, const char* path) gtk_box_pack_start(GTK_BOX(self->controls_box), widget, false, false, 0); } } + self->control_count = (int)controls->len; for (guint i = 0; i < controls->len; i++) free_schematic_control(&g_array_index(controls, SchematicControl, i)); g_array_free(controls, true); gtk_widget_show_all(self->controls_box); + request_ui_size(self); } static void send_schematic_path(LiveSpiceGenericUi* self, const char* path) @@ -341,8 +553,6 @@ static LV2UI_Handle instantiate( const LV2_Feature* const* features) { (void)descriptor; - (void)bundle_path; - if (strcmp(plugin_uri, LIVESPICE_GENERIC_URI) != 0) return NULL; @@ -356,6 +566,8 @@ static LV2UI_Handle instantiate( for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { if (strcmp((*feature)->URI, LV2_URID__map) == 0) self->map = (LV2_URID_Map*)(*feature)->data; + else if (strcmp((*feature)->URI, LV2_UI__resize) == 0) + self->resize = (LV2UI_Resize*)(*feature)->data; } if (self->map != NULL) { @@ -364,15 +576,19 @@ static LV2UI_Handle instantiate( } gtk_init_once(); + install_css(bundle_path); self->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(self->root, "livespice-root"); gtk_container_set_border_width(GTK_CONTAINER(self->root), 10); GtkWidget* title = gtk_label_new("LiveSPICE Generic"); + gtk_widget_set_name(title, "livespice-title"); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_box_pack_start(GTK_BOX(self->root), title, false, false, 0); self->path_label = gtk_label_new("No schematic loaded"); + gtk_widget_set_name(self->path_label, "livespice-path"); gtk_label_set_ellipsize(GTK_LABEL(self->path_label), PANGO_ELLIPSIZE_MIDDLE); gtk_widget_set_halign(self->path_label, GTK_ALIGN_START); gtk_box_pack_start(GTK_BOX(self->root), self->path_label, false, false, 0); @@ -380,16 +596,19 @@ static LV2UI_Handle instantiate( GtkWidget* controls = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); GtkWidget* load_button = gtk_button_new_with_label("Load Schematic"); GtkWidget* clear_button = gtk_button_new_with_label("Clear"); + add_css_class(load_button, "livespice-button"); + add_css_class(clear_button, "livespice-button"); gtk_box_pack_start(GTK_BOX(controls), load_button, false, false, 0); gtk_box_pack_start(GTK_BOX(controls), clear_button, false, false, 0); gtk_box_pack_start(GTK_BOX(self->root), controls, false, false, 0); GtkWidget* scroll = gtk_scrolled_window_new(NULL, NULL); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_NEVER); - gtk_widget_set_size_request(scroll, 320, 96); + gtk_widget_set_size_request(scroll, 320, 112); + gtk_widget_set_valign(scroll, GTK_ALIGN_START); self->controls_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); gtk_container_add(GTK_CONTAINER(scroll), self->controls_box); - gtk_box_pack_start(GTK_BOX(self->root), scroll, true, true, 0); + gtk_box_pack_start(GTK_BOX(self->root), scroll, false, false, 0); rebuild_control_panel(self, ""); g_signal_connect(load_button, "clicked", G_CALLBACK(load_schematic_clicked), self); From ce36533a1938736b8ea359397c27fcb1c108180d Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:01:21 +0200 Subject: [PATCH 32/42] Refine LV2 UI layout testing --- Native/LiveSPICE.LV2/Makefile | 12 ++- Native/LiveSPICE.LV2/README.md | 3 + Native/LiveSPICE.LV2/livespice_generic_ui.c | 84 ++++++++++++++++++--- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile index 38d0a832..079d4328 100644 --- a/Native/LiveSPICE.LV2/Makefile +++ b/Native/LiveSPICE.LV2/Makefile @@ -7,11 +7,14 @@ BUNDLE := build/LiveSPICE.lv2 MXR_PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so GENERIC_PLUGIN := $(BUNDLE)/livespice_generic.so GENERIC_UI := $(BUNDLE)/livespice_generic_ui.so +UI_SMOKE := build/livespice_generic_ui_smoke TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl $(BUNDLE)/livespice_generic.ttl ASSETS := $(BUNDLE)/MetalAlpha.png PREFIX ?= $(HOME)/.lv2 +UI_SMOKE_SCHEMATIC ?= ../../Tests/Examples/MXR\ Phase\ 90.schx +UI_SMOKE_IMAGE ?= ../../out/lv2-generic-ui-smoke.png -.PHONY: all install clean test +.PHONY: all install clean test ui-smoke all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(GENERIC_UI) $(TTL) $(ASSETS) @@ -27,6 +30,9 @@ $(GENERIC_PLUGIN): livespice_generic.c | $(BUNDLE) $(GENERIC_UI): livespice_generic_ui.c | $(BUNDLE) $(CC) $(CFLAGS) $(GTK3_CFLAGS) $< $(LDFLAGS) $(GTK3_LIBS) -o $@ +$(UI_SMOKE): livespice_generic_ui.c $(ASSETS) | $(BUNDLE) + $(CC) $(CFLAGS) $(GTK3_CFLAGS) -DLIVESPICE_UI_SMOKE $< $(GTK3_LIBS) -lm -o $@ + $(BUNDLE)/manifest.ttl: manifest.ttl | $(BUNDLE) cp $< $@ @@ -48,5 +54,9 @@ test: all LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/mxr-phase90 LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/generic +ui-smoke: $(UI_SMOKE) + mkdir -p ../../out + $(UI_SMOKE) $(CURDIR)/$(BUNDLE) $(UI_SMOKE_SCHEMATIC) $(UI_SMOKE_IMAGE) + clean: rm -rf build diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md index d5066ac0..1d2a2513 100644 --- a/Native/LiveSPICE.LV2/README.md +++ b/Native/LiveSPICE.LV2/README.md @@ -21,6 +21,7 @@ make -C Native/LiveSPICE.LV2 clean all ```bash make -C Native/LiveSPICE.LV2 test +make -C Native/LiveSPICE.LV2 ui-smoke ``` Expected URIs: @@ -50,3 +51,5 @@ carla-single lv2 https://livespice.org/plugins/generic ``` If the plugin loads successfully, Carla opens/runs the plugin host instead of immediately failing with a plugin description error. + +The `ui-smoke` target renders the generic GTK UI offscreen with the MXR Phase 90 example and writes `out/lv2-generic-ui-smoke.png` for layout review. diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 6bda43e2..17599fca 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -69,9 +69,7 @@ static const char* css_template = " border: 1px solid #4b4b4b; box-shadow: inset 0 1px rgba(255,255,255,0.9), 0 1px rgba(0,0,0,0.25);" "}" ".livespice-control-card {" - " background-color: rgba(235,235,235,0.42); border: 1px solid rgba(35,35,35,0.28);" - " border-radius: 5px; padding: 6px; box-shadow: inset 0 1px rgba(255,255,255,0.45);" - " min-width: 86px; min-height: 88px;" + " padding: 2px 4px; min-width: 82px; min-height: 86px;" "}" ".livespice-control-label { color: #101010; font-weight: 800; font-size: 11px; text-shadow: 0 1px rgba(255,255,255,0.45); }" ".livespice-combo { color: #111; font-weight: 700; }"; @@ -273,13 +271,16 @@ static void request_ui_size(LiveSpiceGenericUi* self) if (self->resize == NULL || self->resize->ui_resize == NULL) return; - int width = 360 + (self->control_count * 104); - if (width < 420) - width = 420; - if (width > 860) - width = 860; + const int base_width = 470; + const int control_width = 96; + const int max_width = 920; + int width = base_width + (self->control_count * control_width); + if (width < base_width) + width = base_width; + if (width > max_width) + width = max_width; - int height = self->control_count > 0 ? 205 : 150; + int height = self->control_count > 0 ? 190 : 145; self->resize->ui_resize(self->resize->handle, width, height); } @@ -419,7 +420,7 @@ static GtkWidget* create_pot_control(const SchematicControl* control) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); add_css_class(box, "livespice-control-card"); - gtk_widget_set_size_request(box, 92, 92); + gtk_widget_set_size_request(box, 86, 90); gtk_widget_set_valign(box, GTK_ALIGN_START); char* name = control_display_name(control); gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Pot"), false, false, 0); @@ -435,7 +436,7 @@ static GtkWidget* create_switch_control(const SchematicControl* control) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); add_css_class(box, "livespice-control-card"); - gtk_widget_set_size_request(box, 92, 92); + gtk_widget_set_size_request(box, 86, 90); gtk_widget_set_valign(box, GTK_ALIGN_START); char* name = control_display_name(control); gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Switch"), false, false, 0); @@ -652,4 +653,63 @@ static const LV2UI_Descriptor descriptor = { LV2_SYMBOL_EXPORT const LV2UI_Descriptor* lv2ui_descriptor(uint32_t index) { return index == 0 ? &descriptor : NULL; -} \ No newline at end of file +} + +#ifdef LIVESPICE_UI_SMOKE +static uint32_t smoke_map_uri(LV2_URID_Map_Handle handle, const char* uri) +{ + (void)handle; + if (strcmp(uri, LV2_ATOM__eventTransfer) == 0) + return 1; + if (strcmp(uri, LV2_ATOM__Path) == 0) + return 2; + if (strcmp(uri, LV2_ATOM__String) == 0) + return 3; + return 100; +} + +static void smoke_write(LV2UI_Controller controller, uint32_t port_index, uint32_t buffer_size, uint32_t port_protocol, const void* buffer) +{ + (void)controller; + (void)port_index; + (void)buffer_size; + (void)port_protocol; + (void)buffer; +} + +int main(int argc, char** argv) +{ + if (argc < 4) + return 2; + + gtk_init(&argc, &argv); + + LV2_URID_Map map = { NULL, smoke_map_uri }; + LV2_Feature map_feature = { LV2_URID__map, &map }; + const LV2_Feature* features[] = { &map_feature, NULL }; + LV2UI_Widget widget = NULL; + LiveSpiceGenericUi* ui = (LiveSpiceGenericUi*)instantiate(&descriptor, LIVESPICE_GENERIC_URI, argv[1], smoke_write, NULL, &widget, features); + if (ui == NULL || widget == NULL) + return 3; + + set_schematic_path(ui, argv[2]); + + GtkWidget* window = gtk_offscreen_window_new(); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(widget)); + gtk_widget_set_size_request(window, 760, 190); + gtk_widget_show_all(window); + + while (gtk_events_pending()) + gtk_main_iteration(); + + GdkPixbuf* pixbuf = gtk_offscreen_window_get_pixbuf(GTK_OFFSCREEN_WINDOW(window)); + if (pixbuf == NULL) + return 4; + + gboolean ok = gdk_pixbuf_save(pixbuf, argv[3], "png", NULL, NULL); + g_object_unref(pixbuf); + cleanup((LV2UI_Handle)ui); + gtk_widget_destroy(window); + return ok ? 0 : 5; +} +#endif \ No newline at end of file From 706c339506c253dd898b0774564e30fa09aa1765 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:03:33 +0200 Subject: [PATCH 33/42] Set LV2 knob zero to top --- Native/LiveSPICE.LV2/livespice_generic_ui.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 17599fca..632b31e3 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -335,7 +335,7 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_fill(cr); cairo_pattern_destroy(cap); - double angle = (-135.0 + (270.0 * value)) * G_PI / 180.0; + double angle = (-90.0 + (270.0 * value)) * G_PI / 180.0; double indicator_inner = radius * 0.18; double indicator_outer = radius * 0.78; cairo_set_source_rgb(cr, 0.02, 0.02, 0.02); From d7fef8d59122233ef30083c14ed64aa733dfe36b Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:06:25 +0200 Subject: [PATCH 34/42] Add LV2 knob scale indicators --- Native/LiveSPICE.LV2/livespice_generic_ui.c | 135 +++++++++++++++++++- 1 file changed, 130 insertions(+), 5 deletions(-) diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 632b31e3..7a60d615 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -35,8 +35,60 @@ typedef struct { bool dragging; double drag_y; double drag_value; + char* low_label; + char* mid_label; + char* high_label; } KnobControl; +typedef enum { + KNOB_SCALE_UNIPOLAR, + KNOB_SCALE_BIPOLAR +} KnobScale; + +typedef struct { + const char* keyword; + KnobScale scale; +} KnobNameRule; + +static const KnobNameRule knob_name_rules[] = { + { "treble", KNOB_SCALE_BIPOLAR }, + { "middle", KNOB_SCALE_BIPOLAR }, + { "mid", KNOB_SCALE_BIPOLAR }, + { "bass", KNOB_SCALE_BIPOLAR }, + { "tone", KNOB_SCALE_BIPOLAR }, + { "eq", KNOB_SCALE_BIPOLAR }, + { "equal", KNOB_SCALE_BIPOLAR }, + { "contour", KNOB_SCALE_BIPOLAR }, + { "tilt", KNOB_SCALE_BIPOLAR }, + { "cut", KNOB_SCALE_BIPOLAR }, + { "boost", KNOB_SCALE_BIPOLAR }, + { "gain", KNOB_SCALE_UNIPOLAR }, + { "drive", KNOB_SCALE_UNIPOLAR }, + { "dist", KNOB_SCALE_UNIPOLAR }, + { "fuzz", KNOB_SCALE_UNIPOLAR }, + { "volume", KNOB_SCALE_UNIPOLAR }, + { "vol", KNOB_SCALE_UNIPOLAR }, + { "master", KNOB_SCALE_UNIPOLAR }, + { "level", KNOB_SCALE_UNIPOLAR }, + { "output", KNOB_SCALE_UNIPOLAR }, + { "input", KNOB_SCALE_UNIPOLAR }, + { "trim", KNOB_SCALE_UNIPOLAR }, + { "trimmer", KNOB_SCALE_UNIPOLAR }, + { "presence", KNOB_SCALE_UNIPOLAR }, + { "presense", KNOB_SCALE_UNIPOLAR }, + { "resonance", KNOB_SCALE_UNIPOLAR }, + { "res", KNOB_SCALE_UNIPOLAR }, + { "speed", KNOB_SCALE_UNIPOLAR }, + { "rate", KNOB_SCALE_UNIPOLAR }, + { "depth", KNOB_SCALE_UNIPOLAR }, + { "mix", KNOB_SCALE_UNIPOLAR }, + { "blend", KNOB_SCALE_UNIPOLAR }, + { "feedback", KNOB_SCALE_UNIPOLAR }, + { "sustain", KNOB_SCALE_UNIPOLAR }, + { "attack", KNOB_SCALE_UNIPOLAR }, + { "release", KNOB_SCALE_UNIPOLAR }, +}; + typedef struct { LV2UI_Write_Function write; LV2UI_Controller controller; @@ -252,6 +304,29 @@ static double clamp_unit(double value) return value; } +static double knob_angle_for_value(double value) +{ + return (135.0 + (270.0 * clamp_unit(value))) * G_PI / 180.0; +} + +static KnobScale classify_knob_scale(const char* name) +{ + if (name == NULL) + return KNOB_SCALE_UNIPOLAR; + + char* lower = g_ascii_strdown(name, -1); + KnobScale scale = KNOB_SCALE_UNIPOLAR; + for (size_t i = 0; i < sizeof(knob_name_rules) / sizeof(knob_name_rules[0]); i++) { + if (strstr(lower, knob_name_rules[i].keyword) != NULL) { + scale = knob_name_rules[i].scale; + break; + } + } + + g_free(lower); + return scale; +} + static void set_label_path(LiveSpiceGenericUi* self, const char* path) { gtk_label_set_text(GTK_LABEL(self->path_label), path != NULL && path[0] != '\0' ? path : "No schematic loaded"); @@ -305,6 +380,24 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) double radius = (size / 2.0) - 5.0; double value = clamp_unit(knob->value); + const double tick_values[] = { 0, 0.5, 1 }; + const char* tick_labels[] = { knob->low_label, knob->mid_label, knob->high_label }; + + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD); + cairo_set_font_size(cr, 8.0); + for (int i = 0; i < 3; i++) { + double tick_angle = knob_angle_for_value(tick_values[i]); + double inner = radius + 1.5; + double outer = radius + 5.5; + cairo_set_source_rgba(cr, 0.02, 0.02, 0.02, 0.80); + cairo_set_line_width(cr, 1.4); + cairo_move_to(cr, center + cos(tick_angle) * inner, center + sin(tick_angle) * inner); + cairo_line_to(cr, center + cos(tick_angle) * outer, center + sin(tick_angle) * outer); + cairo_stroke(cr); + + } + cairo_pattern_t* shadow = cairo_pattern_create_radial(center + 3, center + 5, radius * 0.15, center + 3, center + 5, radius); cairo_pattern_add_color_stop_rgba(shadow, 0, 0, 0, 0, 0.20); cairo_pattern_add_color_stop_rgba(shadow, 1, 0, 0, 0, 0.00); @@ -335,7 +428,7 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_fill(cr); cairo_pattern_destroy(cap); - double angle = (-90.0 + (270.0 * value)) * G_PI / 180.0; + double angle = knob_angle_for_value(value); double indicator_inner = radius * 0.18; double indicator_outer = radius * 0.78; cairo_set_source_rgb(cr, 0.02, 0.02, 0.02); @@ -345,6 +438,24 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); cairo_stroke(cr); + for (int i = 0; i < 3; i++) { + double tick_angle = knob_angle_for_value(tick_values[i]); + cairo_text_extents_t extents; + cairo_text_extents(cr, tick_labels[i], &extents); + double label_radius = radius + 9.0; + double label_x = center + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; + double label_y = center + sin(tick_angle) * label_radius + (extents.height / 2.0); + if (i == 1) + label_y = 9.0 + extents.height; + + cairo_set_source_rgba(cr, 1, 1, 1, 0.78); + cairo_move_to(cr, label_x + 1, label_y + 1); + cairo_show_text(cr, tick_labels[i]); + cairo_set_source_rgba(cr, 0, 0, 0, 0.82); + cairo_move_to(cr, label_x, label_y); + cairo_show_text(cr, tick_labels[i]); + } + cairo_set_source_rgba(cr, 1, 1, 1, 0.85); cairo_set_line_width(cr, 1.2); cairo_move_to(cr, center + cos(angle) * indicator_inner, center + sin(angle) * indicator_inner); @@ -397,15 +508,29 @@ static gboolean knob_scroll(GtkWidget* widget, GdkEventScroll* event, gpointer d static void free_knob_control(gpointer data) { - free(data); + KnobControl* knob = (KnobControl*)data; + free(knob->low_label); + free(knob->mid_label); + free(knob->high_label); + free(knob); } -static GtkWidget* create_knob(double value) +static GtkWidget* create_knob(double value, const char* name) { KnobControl* knob = (KnobControl*)calloc(1, sizeof(KnobControl)); knob->value = clamp_unit(value); + if (classify_knob_scale(name) == KNOB_SCALE_BIPOLAR) { + knob->low_label = strdup("-5"); + knob->mid_label = strdup("0"); + knob->high_label = strdup("+5"); + } + else { + knob->low_label = strdup("0"); + knob->mid_label = strdup("5"); + knob->high_label = strdup("10"); + } knob->area = gtk_drawing_area_new(); - gtk_widget_set_size_request(knob->area, 68, 68); + gtk_widget_set_size_request(knob->area, 88, 88); gtk_widget_add_events(knob->area, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); g_signal_connect(knob->area, "draw", G_CALLBACK(draw_knob), knob); g_signal_connect(knob->area, "button-press-event", G_CALLBACK(knob_button_press), knob); @@ -425,7 +550,7 @@ static GtkWidget* create_pot_control(const SchematicControl* control) char* name = control_display_name(control); gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Pot"), false, false, 0); - GtkWidget* knob = create_knob(control->value); + GtkWidget* knob = create_knob(control->value, name); gtk_widget_set_halign(knob, GTK_ALIGN_CENTER); gtk_box_pack_start(GTK_BOX(box), knob, false, false, 0); free(name); From 13aea242eb5c1b3eecf1d82e2fe2a2f7c9891673 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:10:30 +0200 Subject: [PATCH 35/42] Refine LV2 knob tick scale --- Native/LiveSPICE.LV2/livespice_generic_ui.c | 71 ++++++++++----------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 7a60d615..856a1d01 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -29,22 +29,20 @@ typedef struct { int positions; } SchematicControl; +typedef enum { + KNOB_SCALE_UNIPOLAR, + KNOB_SCALE_BIPOLAR +} KnobScale; + typedef struct { GtkWidget* area; double value; bool dragging; double drag_y; double drag_value; - char* low_label; - char* mid_label; - char* high_label; + KnobScale scale; } KnobControl; -typedef enum { - KNOB_SCALE_UNIPOLAR, - KNOB_SCALE_BIPOLAR -} KnobScale; - typedef struct { const char* keyword; KnobScale scale; @@ -309,6 +307,13 @@ static double knob_angle_for_value(double value) return (135.0 + (270.0 * clamp_unit(value))) * G_PI / 180.0; } +static const char* knob_scale_label(KnobScale scale, int index) +{ + static const char* unipolar_labels[] = { "0", "2.5", "5", "7.5", "10" }; + static const char* bipolar_labels[] = { "-5", "-2.5", "0", "+2.5", "+5" }; + return scale == KNOB_SCALE_BIPOLAR ? bipolar_labels[index] : unipolar_labels[index]; +} + static KnobScale classify_knob_scale(const char* name) { if (name == NULL) @@ -380,18 +385,19 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) double radius = (size / 2.0) - 5.0; double value = clamp_unit(knob->value); - const double tick_values[] = { 0, 0.5, 1 }; - const char* tick_labels[] = { knob->low_label, knob->mid_label, knob->high_label }; + const double label_values[] = { 0, 0.25, 0.5, 0.75, 1 }; cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD); - cairo_set_font_size(cr, 8.0); - for (int i = 0; i < 3; i++) { - double tick_angle = knob_angle_for_value(tick_values[i]); - double inner = radius + 1.5; - double outer = radius + 5.5; + for (int i = 0; i <= 16; i++) { + double tick_value = (double)i / 16.0; + double tick_angle = knob_angle_for_value(tick_value); + bool major = i % 4 == 0; + bool medium = i % 2 == 0; + double inner = radius + 1.0; + double outer = radius + (major ? 8.2 : (medium ? 5.8 : 4.0)); cairo_set_source_rgba(cr, 0.02, 0.02, 0.02, 0.80); - cairo_set_line_width(cr, 1.4); + cairo_set_line_width(cr, major ? 2.1 : (medium ? 1.45 : 1.0)); cairo_move_to(cr, center + cos(tick_angle) * inner, center + sin(tick_angle) * inner); cairo_line_to(cr, center + cos(tick_angle) * outer, center + sin(tick_angle) * outer); cairo_stroke(cr); @@ -438,22 +444,24 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); cairo_stroke(cr); - for (int i = 0; i < 3; i++) { - double tick_angle = knob_angle_for_value(tick_values[i]); + cairo_set_font_size(cr, 9.5); + for (int i = 0; i < 5; i++) { + double tick_angle = knob_angle_for_value(label_values[i]); + const char* label = knob_scale_label(knob->scale, i); cairo_text_extents_t extents; - cairo_text_extents(cr, tick_labels[i], &extents); - double label_radius = radius + 9.0; + cairo_text_extents(cr, label, &extents); + double label_radius = radius + 10.5; double label_x = center + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; double label_y = center + sin(tick_angle) * label_radius + (extents.height / 2.0); - if (i == 1) + if (i == 2) label_y = 9.0 + extents.height; cairo_set_source_rgba(cr, 1, 1, 1, 0.78); cairo_move_to(cr, label_x + 1, label_y + 1); - cairo_show_text(cr, tick_labels[i]); + cairo_show_text(cr, label); cairo_set_source_rgba(cr, 0, 0, 0, 0.82); cairo_move_to(cr, label_x, label_y); - cairo_show_text(cr, tick_labels[i]); + cairo_show_text(cr, label); } cairo_set_source_rgba(cr, 1, 1, 1, 0.85); @@ -508,27 +516,14 @@ static gboolean knob_scroll(GtkWidget* widget, GdkEventScroll* event, gpointer d static void free_knob_control(gpointer data) { - KnobControl* knob = (KnobControl*)data; - free(knob->low_label); - free(knob->mid_label); - free(knob->high_label); - free(knob); + free(data); } static GtkWidget* create_knob(double value, const char* name) { KnobControl* knob = (KnobControl*)calloc(1, sizeof(KnobControl)); knob->value = clamp_unit(value); - if (classify_knob_scale(name) == KNOB_SCALE_BIPOLAR) { - knob->low_label = strdup("-5"); - knob->mid_label = strdup("0"); - knob->high_label = strdup("+5"); - } - else { - knob->low_label = strdup("0"); - knob->mid_label = strdup("5"); - knob->high_label = strdup("10"); - } + knob->scale = classify_knob_scale(name); knob->area = gtk_drawing_area_new(); gtk_widget_set_size_request(knob->area, 88, 88); gtk_widget_add_events(knob->area, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); From 7507f4389318d96f605c46e7112f6ef5fb6e9a3a Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:13:16 +0200 Subject: [PATCH 36/42] Enlarge LV2 knob scale labels --- Native/LiveSPICE.LV2/livespice_generic_ui.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 856a1d01..1854999a 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -444,17 +444,17 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); cairo_stroke(cr); - cairo_set_font_size(cr, 9.5); + cairo_set_font_size(cr, 11.5); for (int i = 0; i < 5; i++) { double tick_angle = knob_angle_for_value(label_values[i]); const char* label = knob_scale_label(knob->scale, i); cairo_text_extents_t extents; cairo_text_extents(cr, label, &extents); - double label_radius = radius + 10.5; + double label_radius = radius + 11.2; double label_x = center + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; double label_y = center + sin(tick_angle) * label_radius + (extents.height / 2.0); if (i == 2) - label_y = 9.0 + extents.height; + label_y = 2.0 + extents.height; cairo_set_source_rgba(cr, 1, 1, 1, 0.78); cairo_move_to(cr, label_x + 1, label_y + 1); From 90adb18f933910988c82664c2aef1b7e7f53b042 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:16:14 +0200 Subject: [PATCH 37/42] Fix Linux editor and LV2 label spacing --- LiveSPICE.Avalonia/SchematicCanvas.cs | 28 ++++++++++++++++++--- Native/LiveSPICE.LV2/livespice_generic_ui.c | 10 ++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/LiveSPICE.Avalonia/SchematicCanvas.cs b/LiveSPICE.Avalonia/SchematicCanvas.cs index 85b02642..4359552f 100644 --- a/LiveSPICE.Avalonia/SchematicCanvas.cs +++ b/LiveSPICE.Avalonia/SchematicCanvas.cs @@ -245,10 +245,32 @@ private void DrawText(DrawingContext context, Transform transform, SymbolLayout. _ => 10 }; FormattedText formatted = new FormattedText(text.String, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, size * Zoom, Brushes.Black); + context.DrawText(formatted, TextOrigin(transform, text, formatted.Width, formatted.Height)); + } + + private APoint TextOrigin(Transform transform, SymbolLayout.Text text, double width, double height) + { APoint point = ToScreen(transform.Apply(text.x)); - double x = point.X - formatted.Width * AlignmentFactor(text.HorizontalAlign); - double y = point.Y - formatted.Height * AlignmentFactor(text.VerticalAlign); - context.DrawText(formatted, new APoint(x, y)); + AVector p1 = TextOffset(transform, text.x, new Circuit.Point( + text.x.x - AlignmentFactor(text.HorizontalAlign), + text.x.y + (1 - AlignmentFactor(text.VerticalAlign)))); + AVector p2 = TextOffset(transform, text.x, new Circuit.Point( + text.x.x - (1 - AlignmentFactor(text.HorizontalAlign)), + text.x.y + AlignmentFactor(text.VerticalAlign))); + + p1 = new AVector(p1.X * width, p1.Y * height); + p2 = new AVector(p2.X * width, p2.Y * height); + + return new APoint( + Math.Min(point.X + p1.X, point.X - p2.X), + Math.Min(point.Y + p1.Y, point.Y - p2.Y)); + } + + private static AVector TextOffset(Transform transform, Circuit.Point origin, Circuit.Point target) + { + Circuit.Point transformedOrigin = transform.Apply(origin); + Circuit.Point transformedTarget = transform.Apply(target); + return new AVector(transformedTarget.x - transformedOrigin.x, transformedTarget.y - transformedOrigin.y); } private static double AlignmentFactor(Alignment alignment) diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 1854999a..9c4a3e34 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -352,7 +352,7 @@ static void request_ui_size(LiveSpiceGenericUi* self) return; const int base_width = 470; - const int control_width = 96; + const int control_width = 108; const int max_width = 920; int width = base_width + (self->control_count * control_width); if (width < base_width) @@ -382,7 +382,7 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) double size = allocation.width < allocation.height ? allocation.width : allocation.height; double center = size / 2.0; - double radius = (size / 2.0) - 5.0; + double radius = (size / 2.0) - 9.0; double value = clamp_unit(knob->value); const double label_values[] = { 0, 0.25, 0.5, 0.75, 1 }; @@ -450,11 +450,11 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) const char* label = knob_scale_label(knob->scale, i); cairo_text_extents_t extents; cairo_text_extents(cr, label, &extents); - double label_radius = radius + 11.2; + double label_radius = radius + 13.4; double label_x = center + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; double label_y = center + sin(tick_angle) * label_radius + (extents.height / 2.0); if (i == 2) - label_y = 2.0 + extents.height; + label_y = 1.0 - extents.y_bearing; cairo_set_source_rgba(cr, 1, 1, 1, 0.78); cairo_move_to(cr, label_x + 1, label_y + 1); @@ -525,7 +525,7 @@ static GtkWidget* create_knob(double value, const char* name) knob->value = clamp_unit(value); knob->scale = classify_knob_scale(name); knob->area = gtk_drawing_area_new(); - gtk_widget_set_size_request(knob->area, 88, 88); + gtk_widget_set_size_request(knob->area, 100, 100); gtk_widget_add_events(knob->area, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); g_signal_connect(knob->area, "draw", G_CALLBACK(draw_knob), knob); g_signal_connect(knob->area, "button-press-event", G_CALLBACK(knob_button_press), knob); From 169712fda773228583d873c16e83cfd5106e23e2 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:24:34 +0200 Subject: [PATCH 38/42] Center LV2 knob label area --- Native/LiveSPICE.LV2/livespice_generic_ui.c | 41 +++++++++++---------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 9c4a3e34..4480e74f 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -352,7 +352,7 @@ static void request_ui_size(LiveSpiceGenericUi* self) return; const int base_width = 470; - const int control_width = 108; + const int control_width = 132; const int max_width = 920; int width = base_width + (self->control_count * control_width); if (width < base_width) @@ -381,8 +381,9 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) gtk_widget_get_allocation(knob->area, &allocation); double size = allocation.width < allocation.height ? allocation.width : allocation.height; - double center = size / 2.0; - double radius = (size / 2.0) - 9.0; + double center_x = allocation.width / 2.0; + double center_y = allocation.height / 2.0; + double radius = (size / 2.0) - 16.0; double value = clamp_unit(knob->value); const double label_values[] = { 0, 0.25, 0.5, 0.75, 1 }; @@ -398,26 +399,26 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) double outer = radius + (major ? 8.2 : (medium ? 5.8 : 4.0)); cairo_set_source_rgba(cr, 0.02, 0.02, 0.02, 0.80); cairo_set_line_width(cr, major ? 2.1 : (medium ? 1.45 : 1.0)); - cairo_move_to(cr, center + cos(tick_angle) * inner, center + sin(tick_angle) * inner); - cairo_line_to(cr, center + cos(tick_angle) * outer, center + sin(tick_angle) * outer); + cairo_move_to(cr, center_x + cos(tick_angle) * inner, center_y + sin(tick_angle) * inner); + cairo_line_to(cr, center_x + cos(tick_angle) * outer, center_y + sin(tick_angle) * outer); cairo_stroke(cr); } - cairo_pattern_t* shadow = cairo_pattern_create_radial(center + 3, center + 5, radius * 0.15, center + 3, center + 5, radius); + cairo_pattern_t* shadow = cairo_pattern_create_radial(center_x + 3, center_y + 5, radius * 0.15, center_x + 3, center_y + 5, radius); cairo_pattern_add_color_stop_rgba(shadow, 0, 0, 0, 0, 0.20); cairo_pattern_add_color_stop_rgba(shadow, 1, 0, 0, 0, 0.00); cairo_set_source(cr, shadow); - cairo_arc(cr, center + 3, center + 5, radius, 0, 2 * G_PI); + cairo_arc(cr, center_x + 3, center_y + 5, radius, 0, 2 * G_PI); cairo_fill(cr); cairo_pattern_destroy(shadow); - cairo_pattern_t* body = cairo_pattern_create_radial(center - radius * 0.35, center - radius * 0.45, radius * 0.1, center, center, radius); + cairo_pattern_t* body = cairo_pattern_create_radial(center_x - radius * 0.35, center_y - radius * 0.45, radius * 0.1, center_x, center_y, radius); cairo_pattern_add_color_stop_rgb(body, 0, 0.96, 0.96, 0.93); cairo_pattern_add_color_stop_rgb(body, 0.42, 0.58, 0.58, 0.56); cairo_pattern_add_color_stop_rgb(body, 1, 0.14, 0.14, 0.14); cairo_set_source(cr, body); - cairo_arc(cr, center, center, radius, 0, 2 * G_PI); + cairo_arc(cr, center_x, center_y, radius, 0, 2 * G_PI); cairo_fill_preserve(cr); cairo_pattern_destroy(body); @@ -425,12 +426,12 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_set_line_width(cr, 1.2); cairo_stroke(cr); - cairo_pattern_t* cap = cairo_pattern_create_linear(center, center - radius, center, center + radius); + cairo_pattern_t* cap = cairo_pattern_create_linear(center_x, center_y - radius, center_x, center_y + radius); cairo_pattern_add_color_stop_rgba(cap, 0, 1, 1, 1, 0.48); cairo_pattern_add_color_stop_rgba(cap, 0.45, 1, 1, 1, 0.06); cairo_pattern_add_color_stop_rgba(cap, 1, 0, 0, 0, 0.22); cairo_set_source(cr, cap); - cairo_arc(cr, center, center, radius - 3, 0, 2 * G_PI); + cairo_arc(cr, center_x, center_y, radius - 3, 0, 2 * G_PI); cairo_fill(cr); cairo_pattern_destroy(cap); @@ -440,8 +441,8 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_set_source_rgb(cr, 0.02, 0.02, 0.02); cairo_set_line_width(cr, 4.0); cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); - cairo_move_to(cr, center + cos(angle) * indicator_inner, center + sin(angle) * indicator_inner); - cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); + cairo_move_to(cr, center_x + cos(angle) * indicator_inner, center_y + sin(angle) * indicator_inner); + cairo_line_to(cr, center_x + cos(angle) * indicator_outer, center_y + sin(angle) * indicator_outer); cairo_stroke(cr); cairo_set_font_size(cr, 11.5); @@ -450,11 +451,11 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) const char* label = knob_scale_label(knob->scale, i); cairo_text_extents_t extents; cairo_text_extents(cr, label, &extents); - double label_radius = radius + 13.4; - double label_x = center + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; - double label_y = center + sin(tick_angle) * label_radius + (extents.height / 2.0); + double label_radius = radius + 17.0; + double label_x = center_x + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; + double label_y = center_y + sin(tick_angle) * label_radius + (extents.height / 2.0); if (i == 2) - label_y = 1.0 - extents.y_bearing; + label_y = 3.0 - extents.y_bearing; cairo_set_source_rgba(cr, 1, 1, 1, 0.78); cairo_move_to(cr, label_x + 1, label_y + 1); @@ -466,8 +467,8 @@ static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) cairo_set_source_rgba(cr, 1, 1, 1, 0.85); cairo_set_line_width(cr, 1.2); - cairo_move_to(cr, center + cos(angle) * indicator_inner, center + sin(angle) * indicator_inner); - cairo_line_to(cr, center + cos(angle) * indicator_outer, center + sin(angle) * indicator_outer); + cairo_move_to(cr, center_x + cos(angle) * indicator_inner, center_y + sin(angle) * indicator_inner); + cairo_line_to(cr, center_x + cos(angle) * indicator_outer, center_y + sin(angle) * indicator_outer); cairo_stroke(cr); return false; @@ -525,7 +526,7 @@ static GtkWidget* create_knob(double value, const char* name) knob->value = clamp_unit(value); knob->scale = classify_knob_scale(name); knob->area = gtk_drawing_area_new(); - gtk_widget_set_size_request(knob->area, 100, 100); + gtk_widget_set_size_request(knob->area, 124, 112); gtk_widget_add_events(knob->area, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); g_signal_connect(knob->area, "draw", G_CALLBACK(draw_knob), knob); g_signal_connect(knob->area, "button-press-event", G_CALLBACK(knob_button_press), knob); From 04df0186736bedacc214a893aed0f58da10816e7 Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 10:30:37 +0200 Subject: [PATCH 39/42] Split Linux UI into dedicated solution --- LiveSPICE.Linux.sln | 75 +++++++++++++++++++++++++++++++++++++++++++++ LiveSPICE.sln | 18 ----------- 2 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 LiveSPICE.Linux.sln diff --git a/LiveSPICE.Linux.sln b/LiveSPICE.Linux.sln new file mode 100644 index 00000000..3905170c --- /dev/null +++ b/LiveSPICE.Linux.sln @@ -0,0 +1,75 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Audio", "Audio\Audio.csproj", "{15F77DB4-1212-4412-871D-DF11022894A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Circuit", "Circuit\Circuit.csproj", "{3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ComputerAlgebra", "ComputerAlgebra", "{1D100B7C-E8B4-4909-878B-9F1EA4D8225D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComputerAlgebra", "ComputerAlgebra\ComputerAlgebra\ComputerAlgebra.csproj", "{B38B8038-EBD1-4326-A01D-5AA1B7275A99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Util", "Util\Util.csproj", "{E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{F70E548C-E2F1-4AF1-A841-A6626B180231}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginCore", "LiveSPICE.PluginCore\LiveSPICE.PluginCore.csproj", "{C039AC03-B5E7-43C4-9C52-F59910B1C5C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia", "LiveSPICE.Avalonia\LiveSPICE.Avalonia.csproj", "{4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginLinux", "LiveSPICE.PluginLinux\LiveSPICE.PluginLinux.csproj", "{A32524C7-1B55-4108-A60E-0A60DE51638F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia.Tests", "LiveSPICE.Avalonia.Tests\LiveSPICE.Avalonia.Tests.csproj", "{0ED01072-3C08-4934-BC3D-904C0D47B6AB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {15F77DB4-1212-4412-871D-DF11022894A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15F77DB4-1212-4412-871D-DF11022894A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15F77DB4-1212-4412-871D-DF11022894A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15F77DB4-1212-4412-871D-DF11022894A2}.Release|Any CPU.Build.0 = Release|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Release|Any CPU.Build.0 = Release|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Release|Any CPU.Build.0 = Release|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Release|Any CPU.Build.0 = Release|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Release|Any CPU.Build.0 = Release|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Release|Any CPU.Build.0 = Release|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Release|Any CPU.Build.0 = Release|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Release|Any CPU.Build.0 = Release|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B38B8038-EBD1-4326-A01D-5AA1B7275A99} = {1D100B7C-E8B4-4909-878B-9F1EA4D8225D} + EndGlobalSection +EndGlobal diff --git a/LiveSPICE.sln b/LiveSPICE.sln index 18f7f50c..82fc98f0 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -38,14 +38,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{FF178A26-25B0-462A-9927-4FD42C710136}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia", "LiveSPICE.Avalonia\LiveSPICE.Avalonia.csproj", "{4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia.Tests", "LiveSPICE.Avalonia.Tests\LiveSPICE.Avalonia.Tests.csproj", "{632F80C2-2019-4EF9-902B-ADE2977CC281}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginCore", "LiveSPICE.PluginCore\LiveSPICE.PluginCore.csproj", "{9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginLinux", "LiveSPICE.PluginLinux\LiveSPICE.PluginLinux.csproj", "{0115FEA0-A941-4C74-B020-A8329BB91CC3}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -108,22 +102,10 @@ Global {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.Build.0 = Release|Any CPU - {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4AECCE30-DAC4-40A4-8D6C-841A5A4610C2}.Release|Any CPU.Build.0 = Release|Any CPU - {632F80C2-2019-4EF9-902B-ADE2977CC281}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {632F80C2-2019-4EF9-902B-ADE2977CC281}.Debug|Any CPU.Build.0 = Debug|Any CPU - {632F80C2-2019-4EF9-902B-ADE2977CC281}.Release|Any CPU.ActiveCfg = Release|Any CPU - {632F80C2-2019-4EF9-902B-ADE2977CC281}.Release|Any CPU.Build.0 = Release|Any CPU {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Debug|Any CPU.Build.0 = Debug|Any CPU {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Release|Any CPU.Build.0 = Release|Any CPU - {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0115FEA0-A941-4C74-B020-A8329BB91CC3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 3d031880f8fdee0a2ddff390d446cf95a602e28f Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Thu, 7 May 2026 11:08:54 +0200 Subject: [PATCH 40/42] Keep Windows VST files unchanged --- LiveSPICE.sln | 6 --- LiveSPICEVst/EditorView.xaml.cs | 1 - LiveSPICEVst/LiveSPICEPlugin.cs | 59 +++++++++++++++++++++--- LiveSPICEVst/LiveSPICEVst.csproj | 5 -- LiveSPICEVst/SimulationInterface.xaml | 5 +- LiveSPICEVst/SimulationInterface.xaml.cs | 1 - 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/LiveSPICE.sln b/LiveSPICE.sln index 82fc98f0..da7a9739 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -38,8 +38,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{FF178A26-25B0-462A-9927-4FD42C710136}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginCore", "LiveSPICE.PluginCore\LiveSPICE.PluginCore.csproj", "{9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,10 +100,6 @@ Global {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.Build.0 = Release|Any CPU - {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9BA93F0F-1E7E-4C44-9D0F-076DEC2AACEE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LiveSPICEVst/EditorView.xaml.cs b/LiveSPICEVst/EditorView.xaml.cs index 40fc8521..d68599e4 100644 --- a/LiveSPICEVst/EditorView.xaml.cs +++ b/LiveSPICEVst/EditorView.xaml.cs @@ -3,7 +3,6 @@ using System.IO; using System.Windows; using System.Windows.Controls; -using LiveSPICE.PluginCore; namespace LiveSPICEVst { diff --git a/LiveSPICEVst/LiveSPICEPlugin.cs b/LiveSPICEVst/LiveSPICEPlugin.cs index 0ae7a0a8..913c14f3 100644 --- a/LiveSPICEVst/LiveSPICEPlugin.cs +++ b/LiveSPICEVst/LiveSPICEPlugin.cs @@ -10,7 +10,6 @@ using System.Xml.Serialization; using AudioPlugSharp; using AudioPlugSharpWPF; -using LiveSPICE.PluginCore; namespace LiveSPICEVst { @@ -80,8 +79,32 @@ public override byte[] SaveState() { byte[] stateData = null; - PluginProgramParameters programParameters = PluginProgramParameters.FromProcessor(SimulationProcessor); - XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); + VstProgramParameters programParameters = new VstProgramParameters + { + SchematicPath = SimulationProcessor.SchematicPath, + OverSample = SimulationProcessor.Oversample, + Iterations = SimulationProcessor.Iterations + }; + + foreach (var wrapper in SimulationProcessor.InteractiveComponents) + { + switch (wrapper) + { + case PotWrapper potWrapper: + programParameters.ControlParameters.Add(new VSTProgramControlParameter { Name = wrapper.Name, Value = potWrapper.PotValue }); + break; + + case DoubleThrowWrapper doubleThrowWrapper: + programParameters.ControlParameters.Add(new VSTProgramControlParameter { Name = wrapper.Name, Value = doubleThrowWrapper.Engaged ? 1 : 0 }); + break; + + case MultiThrowWrapper multiThrowWrapper: + programParameters.ControlParameters.Add(new VSTProgramControlParameter { Name = wrapper.Name, Value = multiThrowWrapper.Position }); + break; + } + } + + XmlSerializer serializer = new XmlSerializer(typeof(VstProgramParameters)); using (MemoryStream memoryStream = new MemoryStream()) { @@ -99,13 +122,13 @@ public override byte[] SaveState() /// Byte array of data to restore public override void RestoreState(byte[] stateData) { - XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); + XmlSerializer serializer = new XmlSerializer(typeof(VstProgramParameters)); try { using (MemoryStream memoryStream = new MemoryStream(stateData)) { - PluginProgramParameters programParameters = serializer.Deserialize(memoryStream) as PluginProgramParameters; + VstProgramParameters programParameters = serializer.Deserialize(memoryStream) as VstProgramParameters; if (string.IsNullOrEmpty(programParameters.SchematicPath)) { @@ -118,7 +141,31 @@ public override void RestoreState(byte[] stateData) LoadSchematic(programParameters.SchematicPath); } - programParameters.ApplyTo(SimulationProcessor); + SimulationProcessor.Oversample = programParameters.OverSample; + SimulationProcessor.Iterations = programParameters.Iterations; + + foreach (VSTProgramControlParameter controlParameter in programParameters.ControlParameters) + { + var wrapper = SimulationProcessor.InteractiveComponents.Where(i => i.Name == controlParameter.Name).SingleOrDefault(); + + if (wrapper != null) + { + switch (wrapper) + { + case PotWrapper potWrapper: + potWrapper.PotValue = controlParameter.Value; + break; + + case DoubleThrowWrapper doubleThrowWrapper: + doubleThrowWrapper.Engaged = (controlParameter.Value == 1); + break; + + case MultiThrowWrapper multiThrowWrapper: + multiThrowWrapper.Position = (int)controlParameter.Value; + break; + } + } + } if (EditorView != null) EditorView.UpdateSchematic(); diff --git a/LiveSPICEVst/LiveSPICEVst.csproj b/LiveSPICEVst/LiveSPICEVst.csproj index 1e6cc75c..ed3b0421 100644 --- a/LiveSPICEVst/LiveSPICEVst.csproj +++ b/LiveSPICEVst/LiveSPICEVst.csproj @@ -20,7 +20,6 @@ - @@ -30,8 +29,4 @@ - - - - \ No newline at end of file diff --git a/LiveSPICEVst/SimulationInterface.xaml b/LiveSPICEVst/SimulationInterface.xaml index 6ff7863e..337ce28d 100644 --- a/LiveSPICEVst/SimulationInterface.xaml +++ b/LiveSPICEVst/SimulationInterface.xaml @@ -4,7 +4,6 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:LiveSPICEVst" - xmlns:core="clr-namespace:LiveSPICE.PluginCore;assembly=LiveSPICE.PluginCore" xmlns:AudioPlugSharpWPF="clr-namespace:AudioPlugSharpWPF;assembly=AudioPlugSharpWPF" xmlns:sys="clr-namespace:System;assembly=mscorlib" mc:Ignorable="d" @@ -52,7 +51,7 @@ - + @@ -63,7 +62,7 @@ - + diff --git a/LiveSPICEVst/SimulationInterface.xaml.cs b/LiveSPICEVst/SimulationInterface.xaml.cs index 9dadb343..bdfe9afa 100644 --- a/LiveSPICEVst/SimulationInterface.xaml.cs +++ b/LiveSPICEVst/SimulationInterface.xaml.cs @@ -1,7 +1,6 @@ using System; using System.Windows.Controls; using System.Windows.Data; -using LiveSPICE.PluginCore; namespace LiveSPICEVst { From 4af7ef284222bf933ce6dab7d578393d90b6960a Mon Sep 17 00:00:00 2001 From: Jegor van Opdorp Date: Fri, 8 May 2026 09:20:55 +0200 Subject: [PATCH 41/42] Address Linux GUI PR review comments --- LiveSPICE.Avalonia/LinuxAudioDriver.cs | 26 ++++++++-- LiveSPICE.Avalonia/PluginEditorWindow.cs | 2 +- LiveSPICE.PluginCore/SimulationProcessor.cs | 2 +- .../Wrappers/MultiThrowWrapper.cs | 2 + LiveSPICE.sln | 4 -- Native/LiveSPICE.LV2/Makefile | 2 +- Native/LiveSPICE.LV2/livespice_generic_ui.c | 24 ++++++++-- .../LiveSPICE.LV2/livespice_mxr_phase90.ttl | 2 + PR_DESCRIPTION_LINUX_GUI_PORT.md | 47 +++++++++++++++++++ 9 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 PR_DESCRIPTION_LINUX_GUI_PORT.md diff --git a/LiveSPICE.Avalonia/LinuxAudioDriver.cs b/LiveSPICE.Avalonia/LinuxAudioDriver.cs index 2e30a628..8d066fcd 100644 --- a/LiveSPICE.Avalonia/LinuxAudioDriver.cs +++ b/LiveSPICE.Avalonia/LinuxAudioDriver.cs @@ -101,9 +101,17 @@ public JackAudioStream(SampleHandler callback, LinuxAudioChannel[] input, LinuxA JackNative.Check(JackNative.jack_activate(client), "activate JACK client"); for (int i = 0; i < input.Length; i++) - JackNative.jack_connect(client, input[i].Name, JackNative.PortName(inputPorts[i])); + { + string sourcePort = input[i].Name; + string destinationPort = JackNative.PortName(inputPorts[i]); + JackNative.Check(JackNative.jack_connect(client, sourcePort, destinationPort), $"connect JACK port {sourcePort} to {destinationPort}"); + } for (int i = 0; i < output.Length; i++) - JackNative.jack_connect(client, JackNative.PortName(outputPorts[i]), output[i].Name); + { + string sourcePort = JackNative.PortName(outputPorts[i]); + string destinationPort = output[i].Name; + JackNative.Check(JackNative.jack_connect(client, sourcePort, destinationPort), $"connect JACK port {sourcePort} to {destinationPort}"); + } } public override double SampleRate => sampleRate; @@ -266,10 +274,20 @@ private static IEnumerable AudioPorts(string command, string? arguments) CreateNoWindow = true }; using Process process = Process.Start(startInfo)!; - string output = process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(1000) || process.ExitCode != 0) + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(1000)) + { + process.Kill(true); + process.WaitForExit(); + return Array.Empty(); + } + _ = errorTask.GetAwaiter().GetResult(); + if (process.ExitCode != 0) return Array.Empty(); + string output = outputTask.GetAwaiter().GetResult(); + return output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Select(i => i.Trim()) .Where(i => i.Length > 0) diff --git a/LiveSPICE.Avalonia/PluginEditorWindow.cs b/LiveSPICE.Avalonia/PluginEditorWindow.cs index 5cd0130d..5897d4ae 100644 --- a/LiveSPICE.Avalonia/PluginEditorWindow.cs +++ b/LiveSPICE.Avalonia/PluginEditorWindow.cs @@ -255,7 +255,7 @@ private Control CreateControl(IComponentWrapper wrapper) panel.Children.Add(toggle); break; case MultiThrowWrapper multiThrowWrapper: - ComboBox comboBox = CreateOptionBox(new[] { 0, 1, 2 }, multiThrowWrapper.Position); + ComboBox comboBox = CreateOptionBox(Enumerable.Range(0, multiThrowWrapper.NumPositions).ToArray(), multiThrowWrapper.Position); comboBox.SelectionChanged += (_, _) => { if (comboBox.SelectedItem is int position) diff --git a/LiveSPICE.PluginCore/SimulationProcessor.cs b/LiveSPICE.PluginCore/SimulationProcessor.cs index 5e598503..b4d1c165 100644 --- a/LiveSPICE.PluginCore/SimulationProcessor.cs +++ b/LiveSPICE.PluginCore/SimulationProcessor.cs @@ -210,7 +210,7 @@ private void AddButtonControl(Circuit.Component component, IButtonControl button { wrapper = button.NumPositions == 2 ? new DoubleThrowWrapper(button, button.Group) - : new MultiThrowWrapper(button, component.Name); + : new MultiThrowWrapper(button, button.Group); buttonGroups[button.Group] = wrapper; InteractiveComponents.Add(wrapper); } diff --git a/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs b/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs index 7fd975cd..14a33194 100644 --- a/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs +++ b/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs @@ -8,6 +8,8 @@ public MultiThrowWrapper(IButtonControl button, string name) : base(button, name { } + public int NumPositions => Sections.Max(i => i.NumPositions); + public int Position { get => Sections[0].Position; diff --git a/LiveSPICE.sln b/LiveSPICE.sln index da7a9739..34c80cde 100644 --- a/LiveSPICE.sln +++ b/LiveSPICE.sln @@ -92,10 +92,6 @@ Global {ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Release|Any CPU.Build.0 = Release|Any CPU - {51F8BCEC-5C29-46BA-8448-06B516715840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {51F8BCEC-5C29-46BA-8448-06B516715840}.Debug|Any CPU.Build.0 = Debug|Any CPU - {51F8BCEC-5C29-46BA-8448-06B516715840}.Release|Any CPU.ActiveCfg = Release|Any CPU - {51F8BCEC-5C29-46BA-8448-06B516715840}.Release|Any CPU.Build.0 = Release|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile index 079d4328..f4e74a00 100644 --- a/Native/LiveSPICE.LV2/Makefile +++ b/Native/LiveSPICE.LV2/Makefile @@ -47,7 +47,7 @@ $(BUNDLE)/MetalAlpha.png: ../../LiveSPICEVst/Images/MetalAlpha.png | $(BUNDLE) install: all mkdir -p $(PREFIX) - rm -rf $(PREFIX)/LiveSPICE-MXR-Phase90.lv2 + rm -rf $(PREFIX)/LiveSPICE.lv2 cp -r $(BUNDLE) $(PREFIX)/ test: all diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c index 4480e74f..b70de029 100644 --- a/Native/LiveSPICE.LV2/livespice_generic_ui.c +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -509,7 +509,16 @@ static gboolean knob_motion(GtkWidget* widget, GdkEventMotion* event, gpointer d static gboolean knob_scroll(GtkWidget* widget, GdkEventScroll* event, gpointer data) { KnobControl* knob = (KnobControl*)data; - double delta = event->direction == GDK_SCROLL_UP ? 0.025 : -0.025; + double delta = 0.0; + if (event->direction == GDK_SCROLL_UP) + delta = 0.025; + else if (event->direction == GDK_SCROLL_DOWN) + delta = -0.025; + else if (event->direction == GDK_SCROLL_SMOOTH) + delta = fmax(-0.1, fmin(0.1, -event->delta_y * 0.025)); + else + return false; + knob->value = clamp_unit(knob->value + delta); gtk_widget_queue_draw(widget); return true; @@ -607,11 +616,18 @@ static void send_schematic_path(LiveSpiceGenericUi* self, const char* path) uint8_t buffer[4096]; lv2_atom_forge_set_buffer(&self->forge, buffer, sizeof(buffer)); - LV2_Atom_Forge_Ref ref = lv2_atom_forge_path(&self->forge, path, (uint32_t)strlen(path) + 1); - if (ref == 0) + + LV2_Atom_Forge_Frame sequence_frame; + LV2_Atom_Forge_Ref sequence_ref = lv2_atom_forge_sequence_head(&self->forge, &sequence_frame, 0); + if (sequence_ref == 0) + return; + + lv2_atom_forge_frame_time(&self->forge, 0); + if (lv2_atom_forge_path(&self->forge, path, (uint32_t)strlen(path) + 1) == 0) return; - LV2_Atom* atom = lv2_atom_forge_deref(&self->forge, ref); + lv2_atom_forge_pop(&self->forge, &sequence_frame); + LV2_Atom* atom = lv2_atom_forge_deref(&self->forge, sequence_ref); self->write(self->controller, PORT_CONTROL_EVENTS, lv2_atom_total_size(atom), self->atom_event_transfer, atom); } diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl index d6330b24..1d8c7ab0 100644 --- a/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl @@ -5,6 +5,7 @@ @prefix rdfs: . @prefix state: . @prefix units: . +@prefix urid: . @prefix livespice: . @@ -13,6 +14,7 @@ doap:license ; doap:maintainer [ foaf:name "LiveSPICE" ] ; lv2:extensionData state:interface ; + lv2:requiredFeature urid:map ; lv2:optionalFeature lv2:hardRTCapable ; livespice:schematicPath "" ; lv2:port [ diff --git a/PR_DESCRIPTION_LINUX_GUI_PORT.md b/PR_DESCRIPTION_LINUX_GUI_PORT.md new file mode 100644 index 00000000..a311d973 --- /dev/null +++ b/PR_DESCRIPTION_LINUX_GUI_PORT.md @@ -0,0 +1,47 @@ +# Title + +Add Linux GUI, audio, and LV2 plugin port + +# Description + +## Summary + +Adds a Linux-focused LiveSPICE port alongside the existing Windows application instead of replacing it. + +This branch introduces an Avalonia desktop editor, Linux audio configuration and live simulation support, a shared plugin core for Linux plugin experiments, and native LV2 plugin bundles with a GTK3 UI. The main Windows solution and Windows WPF/VST UI paths are intentionally left unchanged; Linux-specific projects live in `LiveSPICE.Linux.sln`. + +## Highlights + +- Adds `LiveSPICE.Avalonia` as a Linux desktop editor with schematic loading, editing, property inspection, waveform viewing, and live audio controls. +- Adds Linux audio backends and configuration flow, including JACK-oriented device naming and a virtual audio mode for validation. +- Adds `LiveSPICE.Avalonia.Tests` coverage for editor interaction, launch/open behavior, settings, audio simulation flow, plugin port behavior, and GUI parity checks. +- Adds `LiveSPICE.PluginCore` and `LiveSPICE.PluginLinux` for Linux plugin-facing simulation/control logic without changing the Windows VST UI. +- Adds native LV2 plugin bundles under `Native/LiveSPICE.LV2`, including an MXR Phase 90 plugin and a generic schematic-loader plugin shell. +- Adds a GTK3 LV2 UI with schematic selection, discovered controls, Windows-style plugin presentation, knob scales/ticks/labels, and host smoke-test support. +- Adds `LiveSPICE.Linux.sln` so Linux-specific projects can be built independently from the Windows WPF solution. +- Keeps Windows-facing paths unchanged relative to the `linux` base after the final cleanup commit. + +## Validation + +- `dotnet build LiveSPICE.Linux.sln --no-restore` +- Review fix validation: `dotnet test LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj --no-build --filter "FullyQualifiedName~PluginProgramParametersRoundTripProcessorSettings|FullyQualifiedName~LinuxPluginStateRoundTripsProcessorSettings|FullyQualifiedName~PluginEditorCreatesOverlayControlsForInteractiveSchematic|FullyQualifiedName~SimulationProcessorPassesThroughWhenNoSchematicIsLoaded|FullyQualifiedName~SimulationProcessorProcessesLoadedRcSchematic|FullyQualifiedName~SimulationAndAudioTests" --logger "console;verbosity=minimal"` passed 17 tests. +- Focused Avalonia tests for schematic interaction, settings, and menu-open behavior passed: 17 tests. +- Native LV2 build/test/UI smoke/install flow passed with `make -C Native/LiveSPICE.LV2 clean all test ui-smoke install`. +- Carla was able to load the generic LV2 plugin with `carla-single lv2 https://livespice.org/plugins/generic`. +- Codacy analysis was clean on edited C# files where supported. `.sln`, `.xaml`, and `.csproj` files were reported unsupported by the configured Codacy tools. + A later Codacy retry for the review-comment fixes was blocked because the Codacy MCP install tool failed to create `.codacy/cli.sh` with `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string`. + +## Notes for reviewers + +The Linux GUI/plugin work is intentionally additive. Reviewers can focus on: + +- `LiveSPICE.Avalonia/*` +- `LiveSPICE.Avalonia.Tests/*` +- `LiveSPICE.PluginCore/*` +- `LiveSPICE.PluginLinux/*` +- `Native/LiveSPICE.LV2/*` +- `LiveSPICE.Linux.sln` + +The native generic LV2 plugin currently provides host-loadable state/UI plumbing and pass-through audio; full schematic DSP execution inside the native LV2 shell remains follow-up work. + +The existing Windows WPF application and Windows VST UI are not part of this port and were verified unchanged against the `linux` branch for `LiveSPICE.sln`, `LiveSPICE/`, `LiveSPICEVst/`, `MockVst/`, and `SchematicControls/`. From 5b07b4c85992de6d5324b9bf771c4b156d14a007 Mon Sep 17 00:00:00 2001 From: Charles <252065487+tobleromed@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:13:55 -0700 Subject: [PATCH 42/42] Wire CoreAudio into the Avalonia GUI; make its test suite runnable The Avalonia GUI from PR #272 discovers audio backends through Audio.Driver.Drivers, which reflects over loaded assemblies - and .NET loads assemblies lazily, so a ProjectReference alone is not enough. Add the reference and force the load in AvaloniaAudioDrivers.Available (via GC.KeepAlive: a discarded typeof gets elided in Release builds). With that, the GUI's audio configuration sees every Core Audio device on macOS. A discovery test asserts the driver is present, on the driver rather than its devices so it also holds on CI runners with no audio hardware. The test suite previously hung when run as a whole and had 3 failures even per class: each test class guarded AppBuilder.Setup with its own class-local flag, but Setup is once-per-process ("Setup was already called"), and desktop SetupWithoutStarting never starts a dispatcher loop, so Dispatcher.UIThread.Invoke could hang forever. Replace the per-class setup with Avalonia.Headless.XUnit: one headless application per process via [AvaloniaTestApplication], and [AvaloniaFact] on the tests that touch controls or windows so they run on the headless dispatcher thread. The full suite now passes 41/41 in ~2 s, needs no display, and runs in the macOS CI job. Both LiveSPICE.CLI and the merged LiveSPICE.Headless can render offline; the CLI is the supported tool (it also does live playback and device enumeration). LiveSPICE.Headless is kept untouched to avoid merge friction while upstream PR #272 is still open; folding them together is a follow-up. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 6 +++++ LiveSPICE.Avalonia.Tests/AssemblyInfo.cs | 19 +++++++++++++ .../AudioDriverDiscoveryTests.cs | 27 +++++++++++++++++++ .../LiveSPICE.Avalonia.Tests.csproj | 1 + LiveSPICE.Avalonia.Tests/PluginPortTests.cs | 21 +++------------ .../SchematicCanvasInteractionTests.cs | 21 ++++++++------- LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs | 6 +++++ LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj | 1 + 8 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01e2408a..4bd1cc9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,3 +76,9 @@ jobs: dotnet run -c Release --no-build -- render \ --schematic "../Tests/Examples/Passive 1stOrder Lowpass RC.schx" \ --input /tmp/tone.wav --output /tmp/out.wav + # The Avalonia GUI and its tests run headless (Avalonia.Headless.XUnit), so they work on + # runners with no display or audio hardware. + - name: Build Avalonia GUI + run: dotnet build LiveSPICE.Linux.sln -c Release + - name: Run Avalonia tests + run: dotnet test LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj -c Release --no-build diff --git a/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs b/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs index 21712008..0dd02ca9 100644 --- a/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs +++ b/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs @@ -1,3 +1,22 @@ +using Avalonia; +using Avalonia.Headless; +using LiveSPICE.Avalonia; +using LiveSPICE.Avalonia.Tests; using Xunit; [assembly: CollectionBehavior(DisableTestParallelization = true)] + +// One headless Avalonia application for the whole test process. AppBuilder.Setup can only run +// once per process, so per-class setup helpers ("Setup was already called") and desktop +// SetupWithoutStarting (no dispatcher loop, so Dispatcher.UIThread.Invoke hangs) both fail when +// the suite runs together. Tests that touch controls or windows use [AvaloniaFact], which runs +// them on the headless dispatcher thread. +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace LiveSPICE.Avalonia.Tests; + +public class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} diff --git a/LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs b/LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs new file mode 100644 index 00000000..cf562be2 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Runtime.InteropServices; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public class AudioDriverDiscoveryTests +{ + [Fact] + public void CoreAudioDriverIsDiscoveredOnMacOS() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; + + // The driver must appear even with no audio hardware (CI runners), so assert on the + // driver, not its devices. + var drivers = AvaloniaAudioDrivers.Available(); + Assert.Contains(drivers, d => d.Name == "Core Audio"); + } + + [Fact] + public void VirtualDriverIsAlwaysAvailable() + { + var drivers = AvaloniaAudioDrivers.Available(); + Assert.Contains(drivers, d => d.Devices.Any(dev => dev.OutputChannels.Length > 0)); + } +} diff --git a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj index 014beae9..a0fc0436 100644 --- a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj +++ b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj @@ -11,6 +11,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs index 6fc73443..84039424 100644 --- a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -1,5 +1,6 @@ using AudioPlugSharp; using System.IO; +using Avalonia.Headless.XUnit; using Avalonia.Threading; using LiveSPICE.Avalonia; using LiveSPICE.PluginCore; @@ -10,8 +11,6 @@ namespace LiveSPICE.Avalonia.Tests; public class PluginPortTests { - private static bool avaloniaInitialized; - [Fact] public void LinuxPluginInitializesMonoPorts() { @@ -38,10 +37,9 @@ public void LinuxPluginStartsWithoutHardcodedSchematicByDefault() Assert.DoesNotContain("LiveSPICE.PluginLinux.DefaultSchematic.schx", typeof(LiveSPICELinuxPlugin).Assembly.GetManifestResourceNames()); } - [Fact] + [AvaloniaFact] public void LinuxPluginCreatesAvaloniaEditorBoundToProcessor() { - EnsureAvalonia(); LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); PluginEditorWindow editor = RunOnUiThread(plugin.CreateEditorWindow); try @@ -63,10 +61,9 @@ public void LinuxPluginCreatesAvaloniaEditorBoundToProcessor() } } - [Fact] + [AvaloniaFact] public void PluginEditorSettingsUpdateSharedProcessor() { - EnsureAvalonia(); SimulationProcessor processor = new SimulationProcessor(); PluginEditorWindow editor = RunOnUiThread(() => new PluginEditorWindow(processor)); try @@ -164,10 +161,9 @@ public void SimulationProcessorProcessesLoadedRcSchematic() Assert.DoesNotContain(output[0], double.IsNaN); } - [Fact] + [AvaloniaFact] public void PluginLoadsMxrPhase90WithControlsAndProducesStableAudio() { - EnsureAvalonia(); LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); string schematicPath = FindFixture("Tests/Examples/MXR Phase 90.schx"); plugin.LoadSchematic(schematicPath); @@ -270,15 +266,6 @@ private static void AssertControlValuesEqual(SimulationProcessor expected, Simul } } - private static void EnsureAvalonia() - { - if (avaloniaInitialized) - return; - - Program.BuildAvaloniaApp().SetupWithoutStarting(); - avaloniaInitialized = true; - } - private static T RunOnUiThread(Func action) { return Dispatcher.UIThread.CheckAccess() diff --git a/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs index 38d97b41..88d05050 100644 --- a/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs +++ b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs @@ -1,4 +1,5 @@ using Circuit; +using Avalonia.Headless.XUnit; using Avalonia.Threading; using LiveSPICE.Avalonia; using Xunit; @@ -7,7 +8,7 @@ namespace LiveSPICE.Avalonia.Tests; public sealed class SchematicCanvasInteractionTests { - [Fact] + [AvaloniaFact] public void ClickSelectsSymbol() { TestContext context = TestContext.Load(); @@ -17,7 +18,7 @@ public void ClickSelectsSymbol() Assert.Same(symbol.Component, context.Canvas.SelectedObjects.Single()); } - [Fact] + [AvaloniaFact] public void ControlClickTogglesSelection() { TestContext context = TestContext.Load(); @@ -32,7 +33,7 @@ public void ControlClickTogglesSelection() Assert.Single(context.Canvas.SelectedObjects); } - [Fact] + [AvaloniaFact] public void DragSelectedSymbolRecordsOneUndo() { TestContext context = TestContext.Load(); @@ -52,7 +53,7 @@ public void DragSelectedSymbolRecordsOneUndo() Assert.Equal(before + new Coord(30, 0), symbol.Position); } - [Fact] + [AvaloniaFact] public void RectangleDragSelectsMultipleElements() { TestContext context = TestContext.Load(); @@ -64,7 +65,7 @@ public void RectangleDragSelectsMultipleElements() Assert.True(context.Canvas.SelectedObjects.Count >= 2); } - [Fact] + [AvaloniaFact] public void DeleteSelectionIsUndoable() { TestContext context = TestContext.Load(); @@ -78,7 +79,7 @@ public void DeleteSelectionIsUndoable() Assert.Equal(before, context.Document.Schematic.Elements.Count()); } - [Fact] + [AvaloniaFact] public void WireClicksCreateUndoableWire() { TestContext context = TestContext.Load(); @@ -95,7 +96,7 @@ public void WireClicksCreateUndoableWire() Assert.Equal(before, context.Document.Schematic.Wires.Count()); } - [Fact] + [AvaloniaFact] public void CopyPasteDuplicatesSelection() { TestContext context = TestContext.Load(); @@ -112,7 +113,7 @@ public void CopyPasteDuplicatesSelection() Assert.Equal(before, context.Document.Schematic.Elements.Count()); } - [Fact] + [AvaloniaFact] public void DrawTransformMatchesSpeakerTerminals() { TestContext context = TestContext.Load(); @@ -122,7 +123,7 @@ public void DrawTransformMatchesSpeakerTerminals() Assert.Equal(speaker.MapTerminal(terminal), SchematicCanvas.TestTransformPoint(speaker, speaker.Component.LayoutSymbol().MapTerminal(terminal))); } - [Fact] + [AvaloniaFact] public void DrawTransformMatchesGroundTerminal() { Symbol ground = new Symbol(new Ground()) { Position = new Coord(120, -40), Rotation = 1 }; @@ -131,7 +132,7 @@ public void DrawTransformMatchesGroundTerminal() Assert.Equal(ground.MapTerminal(terminal), SchematicCanvas.TestTransformPoint(ground, ground.Component.LayoutSymbol().MapTerminal(terminal))); } - [Fact] + [AvaloniaFact] public void ResistorValueAndNameTextAreOutsideBody() { SymbolLayout layout = new Resistor().LayoutSymbol(); diff --git a/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs b/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs index 6c53c3d2..b6ef3a06 100644 --- a/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs +++ b/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs @@ -9,6 +9,12 @@ internal static class AvaloniaAudioDrivers { public static IReadOnlyList Available() { + // Audio.Driver.Drivers discovers backends by reflecting over loaded assemblies, and + // .NET only loads an assembly when a type from it is first used - a ProjectReference + // alone is not enough. Touch the macOS backend so it is loaded before the scan. + // (A discarded typeof gets elided in Release builds; GC.KeepAlive is a real call.) + GC.KeepAlive(typeof(global::CoreAudio.Driver)); + List drivers = new List(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) AddIfUsable(drivers, () => new LinuxAudioDriver()); diff --git a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj index 73382196..2777c32a 100644 --- a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj +++ b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj @@ -17,6 +17,7 @@ +