Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Circuit/AudioSimulationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input>().Select(i => i.In).SingleOrDefault();
if (inputExpression == null)
Input[] inputs = circuit.Components.OfType<Input>().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<Speaker>())
Expand Down
39 changes: 38 additions & 1 deletion Circuit/Simulation/Simulation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ public Simulation(TransientSolution Solution)
foreach (Expression i in Solution.Solutions.OfType<NewtonIteration>().SelectMany(i => i.Unknowns))
AddGlobal(i.Evaluate(t_t1));

Reset();

InvalidateProcess();
}

/// <summary>
/// Reset the simulation state to the solution's initial conditions and t = 0.
/// </summary>
public void Reset()
{
// Set the global values to the initial conditions of the solution.
foreach (KeyValuePair<Expression, GlobalExpr<double>> i in globals)
{
Expand All @@ -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();
/// <summary>
/// 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.
/// </summary>
/// <returns>True if the state was transferred; false if the time steps are incompatible.</returns>
public bool CopyStateFrom(Simulation Other)
{
if (!Solution.TimeStep.Equals(Other.Solution.TimeStep))
return false;

foreach (KeyValuePair<Expression, GlobalExpr<double>> i in globals)
{
if (Other.globals.TryGetValue(i.Key, out GlobalExpr<double> state))
i.Value.Value = state.Value;
}
n = Other.n;
return true;
}

/// <summary>
Expand Down
171 changes: 171 additions & 0 deletions LiveSPICE.Avalonia.Tests/SimulationProcessorLiveTests.cs
Original file line number Diff line number Diff line change
@@ -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<PotWrapper>().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<PotWrapper>().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<PotWrapper>().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.");
}

/// <summary>Run several buffers and return the last one, letting background rebuilds land.</summary>
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);
}
}
64 changes: 44 additions & 20 deletions LiveSPICE.Avalonia/LiveAudioProcessor.cs
Original file line number Diff line number Diff line change
@@ -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<double>();
private double[] outputSamples = Array.Empty<double>();
private readonly double[][] inputChannels = { Array.Empty<double>() };
private readonly double[][] outputChannels = { Array.Empty<double>() };
private long tonePosition;

public LiveAudioProcessor(Schematic schematic)
{
Expand All @@ -20,49 +28,65 @@ 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)
buffer.Clear();
return Array.Empty<double>();
}

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;
}
}
}
1 change: 1 addition & 0 deletions LiveSPICE.CLI/LiveSPICE.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<ProjectReference Include="..\Circuit\Circuit.csproj" />
<ProjectReference Include="..\ComputerAlgebra\ComputerAlgebra\ComputerAlgebra.csproj" />
<ProjectReference Include="..\CoreAudio\CoreAudio.csproj" />
<ProjectReference Include="..\LiveSPICE.PluginCore\LiveSPICE.PluginCore.csproj" />
<ProjectReference Include="..\Util\Util.csproj" />
</ItemGroup>
</Project>
Loading
Loading