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) {