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..0dd2df37 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,35 @@ 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.
+ ///
+ /// 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.
+ ///
+ /// 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
new file mode 100644
index 00000000..a42e2d00
--- /dev/null
+++ b/LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs
@@ -0,0 +1,171 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Circuit;
+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);
+ }
+
+ [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);
+ 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..47a0e243 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;
}
@@ -106,20 +114,18 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n
throw toThrow;
}
- if (simulation == null && needRebuild && circuit != null)
- {
- UpdateSimulation(needRebuild);
- 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)
@@ -138,9 +144,11 @@ 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);
+ UpdateSimulation();
needRebuild = false;
needUpdate = false;
}
@@ -150,10 +158,62 @@ public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int n
}
}
- simulation.Run(numSamples, audioInputs, audioOutputs);
+ if (simulation == null)
+ {
+ Bypass(audioInputs, audioOutputs, numSamples);
+ return;
+ }
+
+ 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;
+ }
}
}
+ ///
+ /// 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
+ /// 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 +276,64 @@ 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);
+ 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;
+ }
+
+ ///
+ /// 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 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)
+ {
+ lock (sync)
+ {
+ if (id <= clock)
+ return;
+
+ 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);
new Task(() =>
@@ -226,23 +343,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)
{