From 34c7768c19325904917ba3a924024b8c2f49a814 Mon Sep 17 00:00:00 2001 From: Charles <252065487+tobleromed@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:28:40 -0700 Subject: [PATCH 1/2] Keep the live simulation compiled and recoverable; unify hosts on PluginCore Simulation compiles its inner loop lazily on the first Run after any change to Solution/Oversample/Iterations - a multi-millisecond stall that previously landed inside the audio callback: on every pot change in the plugin path, and on the first buffer in the Avalonia live path. Divergence (NaN/Inf in the circuit) was also unhandled in both, killing audio permanently. Circuit: - Simulation.Reset(): restore the solution's initial conditions and t = 0 (extracted from the constructor). - Simulation.CopyStateFrom(): copy previous-sample state and the sample clock from another simulation. State lives in a dictionary keyed by expression, so unknowns present in both solutions keep their values and new unknowns keep their initial conditions. - AudioSimulationFactory: name the actual input count in the error when a circuit has more than one input. LiveSPICE.PluginCore.SimulationProcessor: - Every rebuild and update now builds a new simulation on the background task, warms it with one scratch sample (forcing the compile), resets it, and publishes it under a lock that only covers CopyStateFrom and the reference swap. The audio thread never waits behind a solve or compile, and pot moves no longer reset capacitor state - previously the choice was one or the other; the state handoff removes the tradeoff. This also replaces the two-branch update path (mutating the live simulation's Solution), which was the audio-thread-compile case. - RunSimulation catches SimulationDiverged with the WPF app's policy: silence the buffer; if divergence came after >1 s, rebuild off-thread and keep playing; if almost immediate, the circuit is genuinely unstable, so stay bypassed rather than thrash rebuilding. - EnsureSimulationReady(): synchronous build + publish, for offline rendering and for hosts that want audio ready before the stream starts. - SetSchematic(): accept an in-memory schematic; SimulationReady exposed. LiveSPICE.Avalonia.LiveAudioProcessor now uses SimulationProcessor instead of driving Simulation directly, which it did with none of the above - plus two fresh array allocations per audio callback. Buffers are now reused; the waveform display still gets a per-callback copy because it is handed across threads to the UI. Start() pre-compiles, so the first callback is clean. LiveSPICE.CLI drops SimulationHost (~190 LOC) for the same SimulationProcessor; gain staging stays in the CLI. Behavior change: while the simulation is still building, play now passes the dry input through (PluginCore's bypass) instead of emitting silence. Two new tests pin the behavior: EnsureSimulationReady must produce real (non-bypass) output on the first call, and a pot change mid-stream must rebuild off-thread while audio keeps running NaN-free. Verified: Avalonia suite 43/43; circuit suite 49/49 vs baselines (the Reset refactor is numerically inert); CLI render of the RC lowpass is numerically identical to before; live play via BlackHole clean; GUI launches. Co-Authored-By: Claude Fable 5 --- Circuit/AudioSimulationFactory.cs | 7 +- Circuit/Simulation/Simulation.cs | 28 ++- .../SimulationProcessorLiveTests.cs | 73 +++++++ LiveSPICE.Avalonia/LiveAudioProcessor.cs | 64 ++++-- LiveSPICE.CLI/LiveSPICE.CLI.csproj | 1 + LiveSPICE.CLI/Program.cs | 88 ++++++-- LiveSPICE.CLI/SimulationHost.cs | 188 ------------------ LiveSPICE.PluginCore/SimulationProcessor.cs | 107 +++++++--- 8 files changed, 299 insertions(+), 257 deletions(-) create mode 100644 LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs delete mode 100644 LiveSPICE.CLI/SimulationHost.cs diff --git a/Circuit/AudioSimulationFactory.cs b/Circuit/AudioSimulationFactory.cs index 6cb86c6b..1289e1c4 100644 --- a/Circuit/AudioSimulationFactory.cs +++ b/Circuit/AudioSimulationFactory.cs @@ -34,9 +34,12 @@ public static Simulation Create(Circuit circuit, TransientSolution solution, int if (iterations <= 0) throw new ArgumentOutOfRangeException(nameof(iterations)); - Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault(); - if (inputExpression == null) + Input[] inputs = circuit.Components.OfType().ToArray(); + if (inputs.Length == 0) throw new NotSupportedException("Circuit has no inputs."); + if (inputs.Length > 1) + throw new NotSupportedException("Circuit has " + inputs.Length + " inputs; only one is supported."); + Expression inputExpression = inputs[0].In; Expression outputExpression = 0; foreach (Speaker speaker in circuit.Components.OfType()) diff --git a/Circuit/Simulation/Simulation.cs b/Circuit/Simulation/Simulation.cs index bac949f5..8c52fbe4 100644 --- a/Circuit/Simulation/Simulation.cs +++ b/Circuit/Simulation/Simulation.cs @@ -134,6 +134,16 @@ public Simulation(TransientSolution Solution) foreach (Expression i in Solution.Solutions.OfType().SelectMany(i => i.Unknowns)) AddGlobal(i.Evaluate(t_t1)); + Reset(); + + InvalidateProcess(); + } + + /// + /// Reset the simulation state to the solution's initial conditions and t = 0. + /// + public void Reset() + { // Set the global values to the initial conditions of the solution. foreach (KeyValuePair> i in globals) { @@ -142,8 +152,24 @@ public Simulation(TransientSolution Solution) Expression init = i_t0.Evaluate(Solution.InitialConditions); i.Value.Value = init is Constant ? (double)init : 0.0; } + n = 0; + } - InvalidateProcess(); + /// + /// Copy the simulation state (previous sample values and the sample clock) from another + /// simulation. This allows a replacement simulation to be built and compiled off the + /// audio thread when the solution changes (e.g. a pot moved), then swapped in without + /// resetting the circuit: unknowns present in both keep their values, unknowns new to + /// this solution keep their initial conditions. + /// + public void CopyStateFrom(Simulation Other) + { + foreach (KeyValuePair> i in globals) + { + if (Other.globals.TryGetValue(i.Key, out GlobalExpr state)) + i.Value.Value = state.Value; + } + n = Other.n; } /// diff --git a/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs b/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs new file mode 100644 index 00000000..7f8bae7b --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using LiveSPICE.PluginCore; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public class SimulationProcessorLiveTests +{ + [Fact] + public void EnsureSimulationReadyProducesRealAudioImmediately() + { + SimulationProcessor processor = new SimulationProcessor { Oversample = 4, Iterations = 8 }; + processor.LoadSchematic(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + processor.SampleRate = 48000; + + processor.EnsureSimulationReady(); + Assert.True(processor.SimulationReady); + + double[] input = Enumerable.Range(0, 512).Select(i => 0.5 * Math.Sin(2 * Math.PI * 440 * i / 48000.0)).ToArray(); + double[] output = new double[512]; + processor.RunSimulation(new[] { input }, new[] { output }, 512); + + // Real simulation output on the very first call - not the not-ready bypass, which would + // copy the input through verbatim. + Assert.Contains(output, i => Math.Abs(i) > 1e-9); + Assert.NotEqual(input, output); + Assert.DoesNotContain(output, double.IsNaN); + } + + [Fact] + public void PotChangeRebuildsOffThreadWhileAudioKeepsRunning() + { + SimulationProcessor processor = new SimulationProcessor { Oversample = 2, Iterations = 8 }; + processor.LoadSchematic(FindFixture("Tests/Circuits/59 Bassman Preamp.schx")); + processor.SampleRate = 48000; + processor.EnsureSimulationReady(); + + PotWrapper pot = processor.InteractiveComponents.OfType().First(); + + double[] input = Enumerable.Range(0, 512).Select(i => 0.1 * Math.Sin(2 * Math.PI * 220 * i / 48000.0)).ToArray(); + double[] output = new double[512]; + + // Move a pot mid-stream, then keep the "audio thread" running long enough to cover the + // update debounce (0.1 s = ~10 buffers) and the background solve + publish. The swap + // carries state across (CopyStateFrom), so audio must stay continuous, finite, and free + // of exceptions throughout. + pot.PotValue = 0.25; + for (int buffer = 0; buffer < 60; ++buffer) + { + processor.RunSimulation(new[] { input }, new[] { output }, 512); + Assert.DoesNotContain(output, double.IsNaN); + Thread.Sleep(5); + } + Assert.True(processor.SimulationReady); + Assert.Contains(output, i => Math.Abs(i) > 1e-12); + } + + 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/LiveAudioProcessor.cs b/LiveSPICE.Avalonia/LiveAudioProcessor.cs index ccd3f2dc..156c6361 100644 --- a/LiveSPICE.Avalonia/LiveAudioProcessor.cs +++ b/LiveSPICE.Avalonia/LiveAudioProcessor.cs @@ -1,13 +1,21 @@ using System; using Circuit; +using LiveSPICE.PluginCore; namespace LiveSPICE.Avalonia; internal sealed class LiveAudioProcessor { - private readonly object sync = new object(); private readonly Schematic schematic; - private Simulation? simulation; + private SimulationProcessor? processor; + + // Reused between callbacks; the audio path should not allocate per buffer. The single-element + // channel arrays are what RunSimulation consumes, updated whenever the buffers grow. + private double[] inputSamples = Array.Empty(); + private double[] outputSamples = Array.Empty(); + private readonly double[][] inputChannels = { Array.Empty() }; + private readonly double[][] outputChannels = { Array.Empty() }; + private long tonePosition; public LiveAudioProcessor(Schematic schematic) { @@ -20,26 +28,28 @@ public LiveAudioProcessor(Schematic schematic) public void Start(double sampleRate, int oversample, int iterations) { - lock (sync) + SimulationProcessor started = new SimulationProcessor { - simulation = AudioSimulationFactory.Create(schematic.Build(), sampleRate, oversample, iterations); - } + SampleRate = sampleRate, + Oversample = oversample, + Iterations = iterations, + }; + started.SetSchematic(schematic); + // Solve and compile now, so the first audio callback finds the simulation ready instead + // of stalling on Simulation's lazy compile. + started.EnsureSimulationReady(); + tonePosition = 0; + processor = started; } public void Stop() { - lock (sync) - { - simulation = null; - } + processor = null; } public double[] Process(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) { - Simulation? current; - lock (sync) - current = simulation; - + SimulationProcessor? current = processor; if (current == null) { foreach (Audio.SampleBuffer buffer in output) @@ -47,22 +57,36 @@ public double[] Process(int count, Audio.SampleBuffer[] input, Audio.SampleBuffe return Array.Empty(); } - double[] inputSamples = new double[count]; + if (inputSamples.Length < count) + { + inputSamples = new double[count]; + outputSamples = new double[count]; + inputChannels[0] = inputSamples; + outputChannels[0] = outputSamples; + } + 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)); + inputSamples[sample] = 0.25 * Math.Sin(2 * Math.PI * 440 * ((tonePosition + sample) / rate)); + tonePosition += count; for (int sample = 0; sample < count; sample++) inputSamples[sample] *= InputScale; - double[] outputSamples = new double[count]; - lock (sync) - current.Run(count, new[] { inputSamples }, new[] { outputSamples }); + // Divergence is handled inside RunSimulation (silence + off-thread rebuild); any other + // exception propagates to the caller's handler, which surfaces it in the UI log. + current.RunSimulation(inputChannels, outputChannels, count); + for (int sample = 0; sample < count; sample++) outputSamples[sample] *= OutputScale; foreach (Audio.SampleBuffer buffer in output) Array.Copy(outputSamples, buffer.Samples, count); - return outputSamples; + + // The returned samples are handed to the UI thread for the waveform display, so they must + // be a copy - the reused buffer will be overwritten by the next callback. + double[] display = new double[count]; + Array.Copy(outputSamples, display, count); + return display; } -} \ No newline at end of file +} diff --git a/LiveSPICE.CLI/LiveSPICE.CLI.csproj b/LiveSPICE.CLI/LiveSPICE.CLI.csproj index 068fcc75..d58b8c53 100644 --- a/LiveSPICE.CLI/LiveSPICE.CLI.csproj +++ b/LiveSPICE.CLI/LiveSPICE.CLI.csproj @@ -15,6 +15,7 @@ + diff --git a/LiveSPICE.CLI/Program.cs b/LiveSPICE.CLI/Program.cs index 6df59dd3..14d4be83 100644 --- a/LiveSPICE.CLI/Program.cs +++ b/LiveSPICE.CLI/Program.cs @@ -2,7 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading; -using Util; +using System.Threading.Tasks; +using LiveSPICE.PluginCore; namespace LiveSPICE.CLI { @@ -132,9 +133,10 @@ static int Render(Args a) string inputFile = a.Required("input"); string outputFile = a.Required("output"); - ConsoleLog log = new ConsoleLog() { Verbosity = MessageType.Info }; - SimulationHost host = NewHost(a, log); - host.Load(schematic); + SimulationProcessor processor = NewProcessor(a); + double inputGain = a.Double("input-gain", 1); + double outputGain = a.Double("output-gain", 1); + processor.LoadSchematic(schematic); Wav wav = Wav.Read(inputFile); double[] input = wav.Channels[0]; @@ -142,7 +144,8 @@ static int Render(Args a) Console.WriteLine("Building simulation at {0} Hz...", wav.SampleRate); DateTime begin = DateTime.Now; - host.BuildNow(wav.SampleRate); + processor.SampleRate = wav.SampleRate; + processor.EnsureSimulationReady(); Console.WriteLine("Built in {0:F2} s.", (DateTime.Now - begin).TotalSeconds); // Chunked, both to mirror the live path and to prove buffer boundaries don't matter. @@ -150,11 +153,15 @@ static int Render(Args a) begin = DateTime.Now; double[] inBlock = new double[Block]; double[] outBlock = new double[Block]; + double[][] inChannels = { inBlock }; + double[][] outChannels = { outBlock }; for (int n = 0; n < input.Length; n += Block) { int count = Math.Min(Block, input.Length - n); Array.Copy(input, n, inBlock, 0, count); - host.Process(count, inBlock, outBlock, wav.SampleRate); + ApplyGain(inBlock, count, inputGain); + processor.RunSimulation(inChannels, outChannels, count); + ApplyGain(outBlock, count, outputGain); Array.Copy(outBlock, 0, output, n, count); } double elapsed = (DateTime.Now - begin).TotalSeconds; @@ -178,9 +185,10 @@ static int Play(Args a) string deviceName = a.String("device", null); double seconds = a.Double("seconds", 0); - ConsoleLog log = new ConsoleLog() { Verbosity = MessageType.Info }; - SimulationHost host = NewHost(a, log); - host.Load(schematic); + SimulationProcessor processor = NewProcessor(a); + double inputGain = a.Double("input-gain", 1); + double outputGain = a.Double("output-gain", 1); + processor.LoadSchematic(schematic); Audio.Device device = FindDevice(deviceName); Audio.Channel[] inputs = SelectChannels(device.InputChannels, a.String("inputs", null), 1); @@ -192,8 +200,10 @@ static int Play(Args a) Console.WriteLine("Outputs: {0}", outputs.Length > 0 ? string.Join(", ", outputs.Select(i => i.Name)) : "(none)"); double[] silence = null; - double builtRate = 0; + double[][] inChannels = new double[1][]; + double[][] outChannels = new double[1][]; long callbacks = 0; + long errors = 0; Audio.Stream stream = null; Audio.Stream.SampleHandler handler = (Count, In, Out, Rate) => @@ -202,11 +212,11 @@ static int Play(Args a) if (Out.Length == 0) return; - if (builtRate != Rate) - { - builtRate = Rate; - host.BuildAsync(Rate); - } + // The setter is a no-op when the rate is unchanged; a change flags a rebuild that + // RunSimulation kicks off on a background task. Until the simulation is ready, + // RunSimulation passes the (dry) input through. + if (processor.SampleRate != Rate) + processor.SampleRate = Rate; double[] inBuffer; if (In.Length > 0) @@ -220,7 +230,21 @@ static int Play(Args a) inBuffer = silence; } - host.Process(Count, inBuffer, Out[0].Samples, Rate); + ApplyGain(inBuffer, Count, inputGain); + inChannels[0] = inBuffer; + outChannels[0] = Out[0].Samples; + try + { + processor.RunSimulation(inChannels, outChannels, Count); + } + catch (Exception) + { + // A background solve failed; its exception surfaces here. Keep the stream + // alive and silent - the count is reported at shutdown. + Array.Clear(Out[0].Samples, 0, Count); + errors++; + } + ApplyGain(Out[0].Samples, Count, outputGain); // Same signal to every selected output channel. for (int i = 1; i < Out.Length; ++i) @@ -228,20 +252,34 @@ static int Play(Args a) }; stream = device.Open(handler, inputs, outputs); - Console.WriteLine("Playing '{0}' at {1} Hz. Press Ctrl-C to stop.", host.Name, stream.SampleRate); + Console.WriteLine("Playing '{0}' at {1} Hz. Press Ctrl-C to stop.", processor.SchematicName, stream.SampleRate); if (inputs.Length > 0) Console.WriteLine("(If input is silent, grant microphone access to your terminal in " + "System Settings > Privacy & Security > Microphone.)"); ManualResetEventSlim done = new ManualResetEventSlim(false); Console.CancelKeyPress += (s, e) => { e.Cancel = true; done.Set(); }; + + // Report readiness without touching the console from the audio thread. + Task.Run(async () => + { + while (!done.IsSet && !processor.SimulationReady) + await Task.Delay(100); + if (processor.SimulationReady) + Console.WriteLine("Simulation ready ({0} Hz, oversample {1}, iterations {2}).", + processor.SampleRate, processor.Oversample, processor.Iterations); + }); + if (seconds > 0) done.Wait(TimeSpan.FromSeconds(seconds)); else done.Wait(); + done.Set(); stream.Stop(); - Console.WriteLine("Stopped after {0} callbacks, {1} rebuild(s).", callbacks, host.Rebuilds); + Console.WriteLine("Stopped after {0} callbacks.", callbacks); + if (errors > 0) + Console.WriteLine("{0} callback(s) failed; last build error above.", errors); return 0; } @@ -346,17 +384,23 @@ static int Loopback(Args a) // --------------------------------------------------------------- utils - static SimulationHost NewHost(Args a, ILog log) + static SimulationProcessor NewProcessor(Args a) { - return new SimulationHost(log) + return new SimulationProcessor() { Oversample = (int)a.Double("oversample", 8), Iterations = (int)a.Double("iterations", 8), - InputGain = a.Double("input-gain", 1), - OutputGain = a.Double("output-gain", 1), }; } + static void ApplyGain(double[] samples, int count, double gain) + { + if (gain == 1) + return; + for (int i = 0; i < count; ++i) + samples[i] *= gain; + } + static Audio.Device FindDevice(string Name) { LoadBackends(); diff --git a/LiveSPICE.CLI/SimulationHost.cs b/LiveSPICE.CLI/SimulationHost.cs deleted file mode 100644 index 27484492..00000000 --- a/LiveSPICE.CLI/SimulationHost.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Circuit; -using ComputerAlgebra; -using Util; - -namespace LiveSPICE.CLI -{ - /// - /// Glue between a loaded schematic and an audio callback: build the transient solution off the - /// audio thread, then run it per buffer. Mono in, mono out (all speakers summed), which is what - /// a guitar signal chain needs. - /// - public class SimulationHost - { - private readonly object sync = new object(); - private readonly ILog log; - - private Circuit.Circuit circuit; - private Simulation simulation; - - public int Oversample { get; set; } = 8; - public int Iterations { get; set; } = 8; - public double InputGain { get; set; } = 1; - public double OutputGain { get; set; } = 1; - - public string Name { get; private set; } - /// Set when a background build failed; rethrown from the next Process call. - private Exception buildException; - private int rebuilds = 0; - public int Rebuilds { get { return rebuilds; } } - - public SimulationHost(ILog Log) { log = Log; } - - public void Load(string FileName) - { - Schematic schematic = Schematic.Load(FileName, log); - circuit = schematic.Build(log); - Name = System.IO.Path.GetFileNameWithoutExtension(FileName); - } - - /// The circuit's single input expression, or null if it has none. - private Expression FindInput() - { - List ins = circuit.Components.OfType().Select(i => i.In).ToList(); - if (ins.Count == 0) - throw new NotSupportedException("Circuit '" + Name + "' has no input component."); - if (ins.Count > 1) - throw new NotSupportedException( - "Circuit '" + Name + "' has " + ins.Count + " inputs; only one is supported."); - return ins[0]; - } - - /// All speakers summed to mono, matching the VST and benchmark conventions. - private Expression FindOutput() - { - Expression sum = 0; - foreach (Speaker i in circuit.Components.OfType()) - sum += i.Out; - if (sum.EqualsZero()) - throw new NotSupportedException("Circuit '" + Name + "' has no speaker output."); - return sum; - } - - /// - /// Build and fully warm a simulation. Must not run on the audio thread: this does the - /// symbolic solve AND forces the Linq expression tree to compile. - /// - private Simulation Build(double SampleRate) - { - Analysis analysis = circuit.Analyze(); - TransientSolution ts = TransientSolution.Solve(analysis, (Real)1 / (SampleRate * Oversample), log); - - Simulation s = new Simulation(ts) - { - Log = log, - Oversample = Oversample, - Iterations = Iterations, - Input = new[] { FindInput() }, - Output = new[] { FindOutput() }, - }; - - // Simulation.Run compiles the process on first call. Left to the audio thread that is a - // multi-millisecond stall inside the callback, so spend it here instead. - s.Run(new double[64], new[] { new double[64] }); - return s; - } - - /// Build synchronously. Used by the offline render path. - public void BuildNow(double SampleRate) - { - Simulation s = Build(SampleRate); - lock (sync) - simulation = s; - } - - /// - /// Build in the background, leaving Process to emit silence until it is ready. The live - /// path needs this because the sample rate isn't known until the stream is open. - /// - public Task BuildAsync(double SampleRate) - { - return Task.Run(() => - { - try - { - Simulation s = Build(SampleRate); - lock (sync) - simulation = s; - log.WriteLine(MessageType.Info, "Simulation ready ({0} Hz, oversample {1}, iterations {2}).", - SampleRate, Oversample, Iterations); - } - catch (Exception Ex) - { - lock (sync) - buildException = Ex; - } - }); - } - - public bool Ready { get { lock (sync) return simulation != null; } } - - /// - /// Run one buffer. Safe to call from an audio callback: no allocation, and the lock is only - /// contended by a publish at the end of a background build. - /// - public void Process(int Count, double[] In, double[] Out, double SampleRate) - { - Exception pending = null; - lock (sync) - { - if (buildException != null) - { - pending = buildException; - buildException = null; - } - } - if (pending != null) - throw pending; - - if (InputGain != 1) - for (int i = 0; i < Count; ++i) - In[i] *= InputGain; - - lock (sync) - { - if (simulation == null) - { - Array.Clear(Out, 0, Count); - return; - } - - try - { - simulation.Run(Count, new[] { In }, new[] { Out }); - } - catch (SimulationDiverged Ex) - { - // Diverging early means the circuit is genuinely unstable; later means a - // transient worth recovering from. - log.WriteLine(MessageType.Error, "Simulation diverged: {0}", Ex.Message); - Array.Clear(Out, 0, Count); - bool retry = Ex.At > SampleRate; - simulation = null; - if (retry) - { - rebuilds++; - BuildAsync(SampleRate); - } - return; - } - catch (Exception Ex) - { - log.WriteLine(MessageType.Error, "Simulation error: {0}", Ex.Message); - Array.Clear(Out, 0, Count); - simulation = null; - return; - } - } - - if (OutputGain != 1) - for (int i = 0; i < Count; ++i) - Out[i] *= OutputGain; - } - } -} diff --git a/LiveSPICE.PluginCore/SimulationProcessor.cs b/LiveSPICE.PluginCore/SimulationProcessor.cs index b4d1c165..8bdee1dc 100644 --- a/LiveSPICE.PluginCore/SimulationProcessor.cs +++ b/LiveSPICE.PluginCore/SimulationProcessor.cs @@ -80,12 +80,20 @@ public int Iterations } } + /// True once a simulation has been built and published; until then RunSimulation bypasses. + public bool SimulationReady => simulation != null; + public void LoadSchematic(string path) { - Schematic newSchematic = Circuit.Schematic.Load(path); - Circuit.Circuit newCircuit = newSchematic.Build(); + SetSchematic(Circuit.Schematic.Load(path), path); + } + + /// Use an already-loaded schematic, e.g. one being edited in memory. + public void SetSchematic(Schematic schematic, string path = "") + { + Circuit.Circuit newCircuit = schematic.Build(); SetCircuit(newCircuit); - Schematic = newSchematic; + Schematic = schematic; SchematicPath = path; } @@ -108,7 +116,7 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n if (simulation == null && needRebuild && circuit != null) { - UpdateSimulation(needRebuild); + UpdateSimulation(); needRebuild = false; } @@ -140,7 +148,7 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n { if (needRebuild || updateSamplesElapsed > delayUpdateSamples) { - UpdateSimulation(needRebuild); + UpdateSimulation(); needRebuild = false; needUpdate = false; } @@ -150,10 +158,42 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n } } - simulation.Run(numSamples, audioInputs, audioOutputs); + try + { + simulation.Run(numSamples, audioInputs, audioOutputs); + } + catch (SimulationDiverged diverged) + { + // The circuit hit a NaN/Inf. Mirror the WPF app's policy: divergence after more + // than a second of audio is likely a transient, so rebuild and keep going; + // divergence almost immediately means the circuit is genuinely unstable, so stay + // bypassed rather than thrash rebuilding every buffer. + foreach (double[] channel in audioOutputs) + Array.Clear(channel, 0, numSamples); + bool retry = diverged.At > sampleRate; + simulation = null; + if (retry) + needRebuild = true; + } } } + /// + /// Build and publish the simulation synchronously. Offline rendering needs this (RunSimulation + /// bypasses until a simulation exists), and live hosts can call it before starting the stream + /// so the first audio callback finds the simulation compiled and ready. + /// + public void EnsureSimulationReady() + { + if (circuit == null) + return; + + int id = Interlocked.Increment(ref update); + Publish(BuildSimulation(), id); + needRebuild = false; + needUpdate = false; + } + private void SetCircuit(Circuit.Circuit newCircuit) { circuit = newCircuit; @@ -216,7 +256,42 @@ private void AddButtonControl(Circuit.Component component, IButtonControl button } } - private void UpdateSimulation(bool rebuild) + /// + /// Build a simulation for the current circuit and settings, compiled and reset. Runs off the + /// audio thread: Simulation compiles its inner loop lazily on first Run, a multi-millisecond + /// stall if left to the audio callback. Warm it with one scratch sample to force the compile, + /// then Reset so a freshly built simulation starts from the solution's initial conditions. + /// + private Simulation BuildSimulation() + { + TransientSolution solution = AudioSimulationFactory.Solve(circuit!, sampleRate, oversample); + Simulation built = AudioSimulationFactory.Create(circuit!, solution, oversample, iterations); + built.Run(1, new[] { new double[1] }, new[] { new double[1] }); + built.Reset(); + return built; + } + + /// + /// Swap in a built simulation, carrying the running state over so the circuit does not reset + /// (capacitors keep their charge, the sample clock keeps counting) when a control changes. + /// The lock only covers the state copy and the reference swap, so the audio thread is never + /// blocked behind a solve or a compile. + /// + private void Publish(Simulation built, int id) + { + lock (sync) + { + if (id <= clock) + return; + + if (simulation != null) + built.CopyStateFrom(simulation); + simulation = built; + clock = id; + } + } + + private void UpdateSimulation() { int id = Interlocked.Increment(ref update); new Task(() => @@ -226,23 +301,7 @@ private void UpdateSimulation(bool rebuild) 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; - } + Publish(BuildSimulation(), id); } catch (Exception ex) { From d94a12a90f090296b865b8759d28f74e6f567db7 Mon Sep 17 00:00:00 2001 From: Charles <252065487+tobleromed@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:57:13 -0700 Subject: [PATCH 2/2] Fix three defects in the live simulation path found by review All three are in the previous commit on this branch and are reachable from ordinary use with no diagnostic. 1. A diverged circuit could never be revived by a control change. When a circuit diverges early, RunSimulation deliberately leaves needRebuild false to avoid rebuilding every buffer. But the interactive-component scan - the only route by which a pot or switch reaches needRebuild - sat below the "simulation == null" early return, so it was unreachable in exactly the state that needed it. Turning the gain down, the obvious thing a player would try, did nothing; only reloading the schematic or restarting the stream recovered. The scan now runs before the bypass check, and a control change with no simulation present rebuilds immediately rather than waiting out the update debounce. 2. CopyStateFrom silently transferred nothing across a sample rate or oversample change, yet still carried the sample clock. State is keyed by expressions of the form x[t - h] with the time step baked in as a literal, so no key matches when h changes: the circuit got the full reset the state handoff exists to prevent, plus a clock placing it at the wrong instant (60 s at 44.1 kHz resumed as 55 s at 48 kHz). It now returns false and leaves the new simulation at its initial conditions instead of carrying a meaningless clock, and Publish records that the handoff was skipped so a caller can tell a transient is expected. 3. The pre-compile warm-up could throw SimulationDiverged. The generated divergence guard fires when (n & 0xFF) == 0, which includes sample 0, so BuildSimulation's one-sample warm-up throws for any circuit with a bad DC operating point. That escaped outside the try guarding Run, was stashed as a build failure and rethrown inside the audio callback, bypassing the divergence policy entirely - and via EnsureSimulationReady it left the Avalonia live path with no processor at all. The warm-up now swallows it; the guard fires again during playback where the policy can handle it. Also fixes the bypass path, which wrote only channel 0 and used Array.CopyTo (whole source array, ignoring numSamples, throwing when the input is longer than the output). It now writes every output channel and copies numSamples. Tests: each fix is pinned by a test that fails without it, verified by reverting the fixes individually. The previous pot test asserted only "not NaN, not silent" and would have passed with the handoff broken; it is replaced by one that asserts the output actually changes across a pot move and shows no discontinuity from a state reset. Avalonia suite 46/46; circuit suite 49/49 against baselines; CLI render numerically identical; live playback via BlackHole clean. Co-Authored-By: Claude Fable 5 --- Circuit/Simulation/Simulation.cs | 13 ++- .../SimulationProcessorLiveTests.cs | 98 +++++++++++++++++++ LiveSPICE.PluginCore/SimulationProcessor.cs | 70 ++++++++++--- 3 files changed, 166 insertions(+), 15 deletions(-) diff --git a/Circuit/Simulation/Simulation.cs b/Circuit/Simulation/Simulation.cs index 8c52fbe4..0dd2df37 100644 --- a/Circuit/Simulation/Simulation.cs +++ b/Circuit/Simulation/Simulation.cs @@ -161,15 +161,26 @@ public void Reset() /// audio thread when the solution changes (e.g. a pot moved), then swapped in without /// resetting the circuit: unknowns present in both keep their values, unknowns new to /// this solution keep their initial conditions. + /// + /// State is keyed by expressions of the form x[t - h], with the time step h baked in as a + /// literal, so nothing can transfer between solutions whose time steps differ. In that + /// case this leaves the simulation at its initial conditions and returns false rather than + /// carrying over a sample clock that would no longer correspond to the same instant in + /// time - the caller can then treat the swap as a fresh start. /// - public void CopyStateFrom(Simulation Other) + /// True if the state was transferred; false if the time steps are incompatible. + public bool CopyStateFrom(Simulation Other) { + if (!Solution.TimeStep.Equals(Other.Solution.TimeStep)) + return false; + foreach (KeyValuePair> i in globals) { if (Other.globals.TryGetValue(i.Key, out GlobalExpr state)) i.Value.Value = state.Value; } n = Other.n; + return true; } /// diff --git a/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs b/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs index 7f8bae7b..a42e2d00 100644 --- a/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs +++ b/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Threading; +using Circuit; using LiveSPICE.PluginCore; using Xunit; @@ -58,6 +59,103 @@ public void PotChangeRebuildsOffThreadWhileAudioKeepsRunning() Assert.Contains(output, i => Math.Abs(i) > 1e-12); } + [Fact] + public void PotChangeActuallyChangesTheAudioAndCarriesStateAcross() + { + // The weaker version of this test (NaN-free and non-silent) would pass even if the pot + // change were dropped, the rebuild never happened, or the state handoff copied nothing. + SimulationProcessor processor = new SimulationProcessor { Oversample = 2, Iterations = 8 }; + processor.LoadSchematic(FindFixture("Tests/Examples/Ibanez Tube Screamer TS-9.schx")); + processor.SampleRate = 48000; + processor.EnsureSimulationReady(); + + PotWrapper pot = processor.InteractiveComponents.OfType().First(); + pot.PotValue = 0.9; + double[] before = RunUntilStable(processor, 40); + + pot.PotValue = 0.1; + double[] after = RunUntilStable(processor, 40); + + // The rebuild must have taken effect: a large pot swing has to move the output. + double delta = before.Zip(after, (a, b) => Math.Abs(a - b)).Max(); + Assert.True(delta > 1e-6, $"Pot change did not alter the output (max delta {delta:G4})."); + + // And the handoff must not have reset the circuit: a state reset shows up as a step + // discontinuity at the swap far larger than the signal's own sample-to-sample motion. + double biggestStep = 0, typicalStep = 0; + for (int i = 1; i < after.Length; ++i) + { + double step = Math.Abs(after[i] - after[i - 1]); + biggestStep = Math.Max(biggestStep, step); + typicalStep += step; + } + typicalStep /= after.Length - 1; + Assert.True(biggestStep < typicalStep * 50 + 1e-9, + $"Output has a discontinuity suggesting the circuit state was reset " + + $"(largest step {biggestStep:G4} vs typical {typicalStep:G4})."); + } + + [Fact] + public void StateHandoffCarriesTheClockOnlyWhenTheTimeStepMatches() + { + // Simulation state is keyed by expressions with the timestep baked in as a literal, so a + // differing timestep transfers no state at all. Carrying the sample counter anyway would + // jump t discontinuously - a 60 s session at 44.1 kHz would resume at 55 s under 48 kHz. + Circuit.Circuit circuit = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")).Build(); + + Simulation source = AudioSimulationFactory.Create(circuit, 44100, 2, 8); + source.Run(new double[1024], new double[1024]); + Assert.Equal(1024, source.At); + + Simulation sameRate = AudioSimulationFactory.Create(circuit, 44100, 2, 8); + Assert.True(sameRate.CopyStateFrom(source), "Matching timesteps should report a successful handoff."); + Assert.Equal(source.At, sameRate.At); + + Simulation differentRate = AudioSimulationFactory.Create(circuit, 48000, 2, 8); + Assert.False(differentRate.CopyStateFrom(source), "A differing timestep cannot transfer state."); + Assert.Equal(0, differentRate.At); + + Simulation differentOversample = AudioSimulationFactory.Create(circuit, 44100, 4, 8); + Assert.False(differentOversample.CopyStateFrom(source), "A differing oversample changes the timestep too."); + Assert.Equal(0, differentOversample.At); + } + + [Fact] + public void ControlChangesAreObservedEvenWhileBypassed() + { + // While no simulation is published RunSimulation bypasses. It must still consume the + // interactive-component flags, otherwise a diverged circuit can never be revived by + // turning a pot down - the only in-session route back to a rebuild. + SimulationProcessor processor = new SimulationProcessor { Oversample = 2, Iterations = 8 }; + processor.LoadSchematic(FindFixture("Tests/Examples/Ibanez Tube Screamer TS-9.schx")); + processor.SampleRate = 48000; + Assert.False(processor.SimulationReady); + + PotWrapper pot = processor.InteractiveComponents.OfType().First(); + pot.PotValue = 0.4; + Assert.True(pot.NeedUpdate); + + double[] input = new double[512]; + double[] output = new double[512]; + processor.RunSimulation(new[] { input }, new[] { output }, 512); + + Assert.False(pot.NeedUpdate, "The control flag was never consumed, so the change is lost."); + } + + /// Run several buffers and return the last one, letting background rebuilds land. + private static double[] RunUntilStable(SimulationProcessor processor, int buffers) + { + double[] input = Enumerable.Range(0, 512) + .Select(i => 0.2 * Math.Sin(2 * Math.PI * 220 * i / 48000.0)).ToArray(); + double[] output = new double[512]; + for (int buffer = 0; buffer < buffers; ++buffer) + { + processor.RunSimulation(new[] { input }, new[] { output }, 512); + Thread.Sleep(5); + } + return (double[])output.Clone(); + } + private static string FindFixture(string relativePath) { DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/LiveSPICE.PluginCore/SimulationProcessor.cs b/LiveSPICE.PluginCore/SimulationProcessor.cs index 8bdee1dc..47a0e243 100644 --- a/LiveSPICE.PluginCore/SimulationProcessor.cs +++ b/LiveSPICE.PluginCore/SimulationProcessor.cs @@ -114,20 +114,18 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n throw toThrow; } - if (simulation == null && needRebuild && circuit != null) - { - UpdateSimulation(); - needRebuild = false; - } - - if (circuit == null || simulation == null) + if (circuit == null) { - audioInputs[0].CopyTo(audioOutputs[0], 0); + Bypass(audioInputs, audioOutputs, numSamples); return; } lock (sync) { + // This scan must run even while bypassed. It is the only route by which a control + // change reaches needRebuild, so if it sat below the bypass return, a simulation that + // had been dropped (a divergence, a failed build) could never be revived by turning a + // pot down - the one thing a player would naturally try. foreach (IComponentWrapper component in InteractiveComponents) { if (component.NeedUpdate) @@ -146,7 +144,9 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n if (needUpdate || needRebuild) { - if (needRebuild || updateSamplesElapsed > delayUpdateSamples) + // With no simulation there is nothing to debounce against - rebuild immediately so + // a bypassed processor recovers on the first control change. + if (needRebuild || simulation == null || updateSamplesElapsed > delayUpdateSamples) { UpdateSimulation(); needRebuild = false; @@ -158,6 +158,12 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n } } + if (simulation == null) + { + Bypass(audioInputs, audioOutputs, numSamples); + return; + } + try { simulation.Run(numSamples, audioInputs, audioOutputs); @@ -178,6 +184,20 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n } } + /// + /// Pass the input through unprocessed. Every output channel is written, so a host with more + /// outputs than the simulation drives does not keep replaying a stale buffer. + /// + private static void Bypass(double[][] audioInputs, double[][] audioOutputs, int numSamples) + { + for (int channel = 0; channel < audioOutputs.Length; ++channel) + { + double[] source = channel < audioInputs.Length ? audioInputs[channel] : audioInputs[0]; + // Copy numSamples, not the whole array: the two need not be the same length. + Array.Copy(source, audioOutputs[channel], Math.Min(numSamples, Math.Min(source.Length, audioOutputs[channel].Length))); + } + } + /// /// Build and publish the simulation synchronously. Offline rendering needs this (RunSimulation /// bypasses until a simulation exists), and live hosts can call it before starting the stream @@ -266,7 +286,18 @@ private Simulation BuildSimulation() { TransientSolution solution = AudioSimulationFactory.Solve(circuit!, sampleRate, oversample); Simulation built = AudioSimulationFactory.Create(circuit!, solution, oversample, iterations); - built.Run(1, new[] { new double[1] }, new[] { new double[1] }); + try + { + built.Run(1, new[] { new double[1] }, new[] { new double[1] }); + } + catch (SimulationDiverged) + { + // The divergence guard also fires on sample 0, so a circuit with a bad DC operating + // point throws here. Swallow it: the compile is what we came for, and the guard will + // fire again during real playback where RunSimulation's policy can handle it. Letting + // it escape would surface as a build failure rethrown into the audio callback, + // bypassing that policy entirely. + } built.Reset(); return built; } @@ -274,8 +305,13 @@ private Simulation BuildSimulation() /// /// Swap in a built simulation, carrying the running state over so the circuit does not reset /// (capacitors keep their charge, the sample clock keeps counting) when a control changes. - /// The lock only covers the state copy and the reference swap, so the audio thread is never - /// blocked behind a solve or a compile. + /// The solve and the compile happen before this, off the audio thread; the lock here covers + /// only the state copy and the reference swap. Note the copy is not free - it is proportional + /// to the number of state variables - so the audio thread can briefly wait on it. + /// + /// A sample rate or oversample change alters the time step, which no state can survive; in + /// that case the new simulation deliberately starts from its initial conditions rather than + /// inheriting a sample clock that would place it at the wrong instant. /// private void Publish(Simulation built, int id) { @@ -284,13 +320,19 @@ private void Publish(Simulation built, int id) if (id <= clock) return; - if (simulation != null) - built.CopyStateFrom(simulation); + if (simulation != null && !built.CopyStateFrom(simulation)) + StateHandoffSkipped = true; simulation = built; clock = id; } } + /// + /// Set when a rebuild could not carry the previous simulation's state across, which means the + /// circuit restarted from its initial conditions and a transient is expected. + /// + public bool StateHandoffSkipped { get; private set; } + private void UpdateSimulation() { int id = Interlocked.Increment(ref update);