diff --git a/Circuit/AudioSimulationFactory.cs b/Circuit/AudioSimulationFactory.cs
new file mode 100644
index 00000000..6cb86c6b
--- /dev/null
+++ b/Circuit/AudioSimulationFactory.cs
@@ -0,0 +1,57 @@
+using ComputerAlgebra;
+using System;
+using System.Linq;
+
+namespace Circuit
+{
+ public static class AudioSimulationFactory
+ {
+ public static TransientSolution Solve(Circuit circuit, double sampleRate, int oversample)
+ {
+ if (circuit == null)
+ throw new ArgumentNullException(nameof(circuit));
+ if (sampleRate <= 0)
+ throw new ArgumentOutOfRangeException(nameof(sampleRate));
+ if (oversample <= 0)
+ throw new ArgumentOutOfRangeException(nameof(oversample));
+
+ return TransientSolution.Solve(circuit.Analyze(), (Real)1 / (sampleRate * oversample));
+ }
+
+ public static Simulation Create(Circuit circuit, double sampleRate, int oversample, int iterations)
+ {
+ return Create(circuit, Solve(circuit, sampleRate, oversample), oversample, iterations);
+ }
+
+ public static Simulation Create(Circuit circuit, TransientSolution solution, int oversample, int iterations)
+ {
+ if (circuit == null)
+ throw new ArgumentNullException(nameof(circuit));
+ if (solution == null)
+ throw new ArgumentNullException(nameof(solution));
+ if (oversample <= 0)
+ throw new ArgumentOutOfRangeException(nameof(oversample));
+ if (iterations <= 0)
+ throw new ArgumentOutOfRangeException(nameof(iterations));
+
+ Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault();
+ if (inputExpression == null)
+ throw new NotSupportedException("Circuit has no inputs.");
+
+ Expression outputExpression = 0;
+ foreach (Speaker speaker in circuit.Components.OfType())
+ outputExpression += speaker.Out;
+
+ if (outputExpression.EqualsZero())
+ throw new NotSupportedException("Circuit has no speaker outputs.");
+
+ return new Simulation(solution)
+ {
+ Oversample = oversample,
+ Iterations = iterations,
+ Input = new[] { inputExpression },
+ Output = new[] { outputExpression }
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/Circuit/Circuit.csproj b/Circuit/Circuit.csproj
index 9a4e5cd0..d0400e95 100644
--- a/Circuit/Circuit.csproj
+++ b/Circuit/Circuit.csproj
@@ -24,6 +24,8 @@
+
+
\ No newline at end of file
diff --git a/LiveSPICE.Headless/LiveSPICE.Headless.csproj b/LiveSPICE.Headless/LiveSPICE.Headless.csproj
new file mode 100644
index 00000000..e6fcc8c5
--- /dev/null
+++ b/LiveSPICE.Headless/LiveSPICE.Headless.csproj
@@ -0,0 +1,14 @@
+
+
+ net8.0
+ Exe
+ LiveSPICE.Headless
+ LiveSPICE.Headless
+ Copyright © 2026
+ 1.0.0.0
+ 1.0.0.0
+
+
+
+
+
\ No newline at end of file
diff --git a/LiveSPICE.Headless/Program.cs b/LiveSPICE.Headless/Program.cs
new file mode 100644
index 00000000..ef4e45cb
--- /dev/null
+++ b/LiveSPICE.Headless/Program.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+
+namespace Circuit.Headless
+{
+ internal static class Program
+ {
+ private static int Main(string[] args)
+ {
+ try
+ {
+ Options options = Options.Parse(args);
+ WaveData inputWave = options.InputWavPath != null ? WaveFile.ReadMono(options.InputWavPath) : null;
+
+ if (inputWave != null)
+ {
+ if (!options.HasExplicitSampleRate)
+ options.SetSampleRate(inputWave.SampleRate);
+ if (!options.HasExplicitSampleCount)
+ options.SetSampleCount(inputWave.Samples.Length);
+ }
+
+ Schematic schematic = Schematic.Load(options.SchematicPath);
+ Circuit circuit = schematic.Build();
+ Simulation simulation = AudioSimulationFactory.Create(circuit, options.SampleRate, options.Oversample, options.Iterations);
+
+ double[] input = new double[options.BatchSize];
+ double[] output = new double[options.BatchSize];
+ double[] renderedOutput = options.OutputWavPath != null ? new double[options.SampleCount] : null;
+
+ double min = double.PositiveInfinity;
+ double max = double.NegativeInfinity;
+ double sumSquares = 0;
+ double last = 0;
+
+ int remaining = options.SampleCount;
+ int generated = 0;
+ while (remaining > 0)
+ {
+ int count = Math.Min(remaining, input.Length);
+ FillInput(input, count, generated, options, inputWave);
+ Array.Clear(output, 0, count);
+
+ simulation.Run(count, new[] { input }, new[] { output });
+
+ for (int i = 0; i < count; i++)
+ {
+ double sample = output[i];
+ min = Math.Min(min, sample);
+ max = Math.Max(max, sample);
+ sumSquares += sample * sample;
+ last = sample;
+ }
+
+ if (renderedOutput != null)
+ Array.Copy(output, 0, renderedOutput, generated, count);
+
+ remaining -= count;
+ generated += count;
+ }
+
+ double rms = Math.Sqrt(sumSquares / options.SampleCount);
+
+ if (renderedOutput != null)
+ WaveFile.WriteMono16(options.OutputWavPath, renderedOutput, (int)Math.Round(options.SampleRate));
+
+ Console.WriteLine("Headless simulation completed.");
+ Console.WriteLine($"Schematic: {Path.GetFileName(options.SchematicPath)}");
+ Console.WriteLine($"Samples: {options.SampleCount} at {options.SampleRate.ToString(CultureInfo.InvariantCulture)} Hz");
+ if (options.InputWavPath != null)
+ Console.WriteLine($"Input WAV: {options.InputWavPath}");
+ if (options.OutputWavPath != null)
+ Console.WriteLine($"Output WAV: {options.OutputWavPath}");
+ Console.WriteLine($"Output min={min.ToString("R", CultureInfo.InvariantCulture)} max={max.ToString("R", CultureInfo.InvariantCulture)} rms={rms.ToString("R", CultureInfo.InvariantCulture)} last={last.ToString("R", CultureInfo.InvariantCulture)}");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ }
+
+ private static void FillInput(double[] input, int count, int offset, Options options, WaveData inputWave)
+ {
+ if (inputWave != null)
+ {
+ Array.Clear(input, 0, input.Length);
+
+ int available = Math.Max(0, Math.Min(count, inputWave.Samples.Length - offset));
+ if (available > 0)
+ Array.Copy(inputWave.Samples, offset, input, 0, available);
+ return;
+ }
+
+ double radiansPerSample = 2 * Math.PI * options.Frequency / options.SampleRate;
+ for (int i = 0; i < count; i++)
+ input[i] = options.Amplitude * Math.Sin((offset + i) * radiansPerSample);
+ }
+
+ private sealed class Options
+ {
+ public string SchematicPath { get; private set; }
+ public double SampleRate { get; private set; } = 48000;
+ public int Oversample { get; private set; } = 1;
+ public int Iterations { get; private set; } = 16;
+ public int SampleCount { get; private set; } = 48000;
+ public int BatchSize { get; private set; } = 256;
+ public double Frequency { get; private set; } = 440;
+ public double Amplitude { get; private set; } = 0.25;
+ public string InputWavPath { get; private set; }
+ public string OutputWavPath { get; private set; }
+ public bool HasExplicitSampleRate { get; private set; }
+ public bool HasExplicitSampleCount { get; private set; }
+
+ public static Options Parse(string[] args)
+ {
+ if (args.Length == 0)
+ throw new ArgumentException("Usage: LiveSPICE.Headless [--input-wav path] [--output-wav path] [--sample-rate hz] [--oversample n] [--iterations n] [--samples n] [--batch-size n] [--frequency hz] [--amplitude value]");
+
+ Options options = new Options
+ {
+ SchematicPath = Path.GetFullPath(args[0])
+ };
+
+ for (int i = 1; i < args.Length; i += 2)
+ {
+ if (i + 1 >= args.Length)
+ throw new ArgumentException($"Missing value for '{args[i]}'.");
+
+ string value = args[i + 1];
+ switch (args[i])
+ {
+ case "--input-wav":
+ options.InputWavPath = Path.GetFullPath(value);
+ break;
+ case "--output-wav":
+ options.OutputWavPath = Path.GetFullPath(value);
+ break;
+ case "--sample-rate":
+ options.SampleRate = ParseDouble(args[i], value);
+ options.HasExplicitSampleRate = true;
+ break;
+ case "--oversample":
+ options.Oversample = ParseInt(args[i], value);
+ break;
+ case "--iterations":
+ options.Iterations = ParseInt(args[i], value);
+ break;
+ case "--samples":
+ options.SampleCount = ParseInt(args[i], value);
+ options.HasExplicitSampleCount = true;
+ break;
+ case "--batch-size":
+ options.BatchSize = ParseInt(args[i], value);
+ break;
+ case "--frequency":
+ options.Frequency = ParseDouble(args[i], value);
+ break;
+ case "--amplitude":
+ options.Amplitude = ParseDouble(args[i], value);
+ break;
+ default:
+ throw new ArgumentException($"Unknown option '{args[i]}'.");
+ }
+ }
+
+ if (!File.Exists(options.SchematicPath))
+ throw new FileNotFoundException("Schematic file not found.", options.SchematicPath);
+ if (options.InputWavPath != null && !File.Exists(options.InputWavPath))
+ throw new FileNotFoundException("Input WAV file not found.", options.InputWavPath);
+ if (options.SampleRate <= 0)
+ throw new ArgumentOutOfRangeException(nameof(options.SampleRate));
+ if (options.Oversample <= 0)
+ throw new ArgumentOutOfRangeException(nameof(options.Oversample));
+ if (options.Iterations <= 0)
+ throw new ArgumentOutOfRangeException(nameof(options.Iterations));
+ if (options.SampleCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(options.SampleCount));
+ if (options.BatchSize <= 0)
+ throw new ArgumentOutOfRangeException(nameof(options.BatchSize));
+
+ return options;
+ }
+
+ private static int ParseInt(string option, string value)
+ {
+ return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)
+ ? parsed
+ : throw new ArgumentException($"Invalid integer value '{value}' for '{option}'.");
+ }
+
+ private static double ParseDouble(string option, string value)
+ {
+ return double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double parsed)
+ ? parsed
+ : throw new ArgumentException($"Invalid numeric value '{value}' for '{option}'.");
+ }
+
+ public void SetSampleRate(double sampleRate)
+ {
+ SampleRate = sampleRate;
+ }
+
+ public void SetSampleCount(int sampleCount)
+ {
+ SampleCount = sampleCount;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/LiveSPICE.Headless/WaveFile.cs b/LiveSPICE.Headless/WaveFile.cs
new file mode 100644
index 00000000..0b1e202c
--- /dev/null
+++ b/LiveSPICE.Headless/WaveFile.cs
@@ -0,0 +1,159 @@
+using System;
+using System.IO;
+using System.Text;
+
+namespace Circuit.Headless
+{
+ internal sealed class WaveData
+ {
+ public WaveData(double[] samples, int sampleRate)
+ {
+ Samples = samples ?? throw new ArgumentNullException(nameof(samples));
+ SampleRate = sampleRate;
+ }
+
+ public double[] Samples { get; }
+
+ public int SampleRate { get; }
+ }
+
+ internal static class WaveFile
+ {
+ public static WaveData ReadMono(string path)
+ {
+ using FileStream stream = File.OpenRead(path);
+ using BinaryReader reader = new BinaryReader(stream, Encoding.ASCII, leaveOpen: false);
+
+ if (ReadFourCc(reader) != "RIFF")
+ throw new InvalidDataException("WAV file is missing a RIFF header.");
+
+ reader.ReadInt32();
+
+ if (ReadFourCc(reader) != "WAVE")
+ throw new InvalidDataException("File is not a WAVE container.");
+
+ ushort audioFormat = 0;
+ ushort channels = 0;
+ int sampleRate = 0;
+ ushort bitsPerSample = 0;
+ byte[] data = null;
+
+ while (reader.BaseStream.Position + 8 <= reader.BaseStream.Length)
+ {
+ string chunkId = ReadFourCc(reader);
+ int chunkSize = reader.ReadInt32();
+ long nextChunk = reader.BaseStream.Position + chunkSize + (chunkSize % 2);
+
+ if (chunkId == "fmt ")
+ {
+ audioFormat = reader.ReadUInt16();
+ channels = reader.ReadUInt16();
+ sampleRate = reader.ReadInt32();
+ reader.ReadInt32();
+ reader.ReadUInt16();
+ bitsPerSample = reader.ReadUInt16();
+ }
+ else if (chunkId == "data")
+ {
+ data = reader.ReadBytes(chunkSize);
+ }
+
+ reader.BaseStream.Position = nextChunk;
+ }
+
+ if (channels == 0 || sampleRate <= 0 || bitsPerSample == 0 || data == null)
+ throw new InvalidDataException("WAV file is missing required format or data chunks.");
+
+ int bytesPerSample = bitsPerSample / 8;
+ int frameSize = bytesPerSample * channels;
+ if (bytesPerSample == 0 || frameSize == 0 || data.Length % frameSize != 0)
+ throw new InvalidDataException("WAV file has an unsupported frame layout.");
+
+ int frameCount = data.Length / frameSize;
+ double[] samples = new double[frameCount];
+
+ for (int frame = 0; frame < frameCount; frame++)
+ {
+ double mixed = 0;
+ for (int channel = 0; channel < channels; channel++)
+ {
+ int offset = frame * frameSize + channel * bytesPerSample;
+ mixed += DecodeSample(data, offset, audioFormat, bitsPerSample);
+ }
+
+ samples[frame] = mixed / channels;
+ }
+
+ return new WaveData(samples, sampleRate);
+ }
+
+ public static void WriteMono16(string path, double[] samples, int sampleRate)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
+
+ using FileStream stream = File.Create(path);
+ using BinaryWriter writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: false);
+
+ const short channels = 1;
+ const short bitsPerSample = 16;
+ int bytesPerSample = bitsPerSample / 8;
+ int dataSize = samples.Length * bytesPerSample;
+ int byteRate = sampleRate * channels * bytesPerSample;
+ short blockAlign = (short)(channels * bytesPerSample);
+
+ WriteFourCc(writer, "RIFF");
+ writer.Write(36 + dataSize);
+ WriteFourCc(writer, "WAVE");
+
+ WriteFourCc(writer, "fmt ");
+ writer.Write(16);
+ writer.Write((short)1);
+ writer.Write(channels);
+ writer.Write(sampleRate);
+ writer.Write(byteRate);
+ writer.Write(blockAlign);
+ writer.Write(bitsPerSample);
+
+ WriteFourCc(writer, "data");
+ writer.Write(dataSize);
+
+ foreach (double sample in samples)
+ {
+ double clipped = Math.Max(-1.0, Math.Min(1.0, sample));
+ short pcm = (short)Math.Round(clipped * short.MaxValue);
+ writer.Write(pcm);
+ }
+ }
+
+ private static double DecodeSample(byte[] data, int offset, ushort audioFormat, ushort bitsPerSample)
+ {
+ return (audioFormat, bitsPerSample) switch
+ {
+ (1, 8) => (data[offset] - 128) / 128.0,
+ (1, 16) => BitConverter.ToInt16(data, offset) / 32768.0,
+ (1, 24) => ReadInt24(data, offset) / 8388608.0,
+ (1, 32) => BitConverter.ToInt32(data, offset) / 2147483648.0,
+ (3, 32) => BitConverter.ToSingle(data, offset),
+ _ => throw new NotSupportedException($"Unsupported WAV format: audioFormat={audioFormat}, bitsPerSample={bitsPerSample}.")
+ };
+ }
+
+ private static int ReadInt24(byte[] data, int offset)
+ {
+ int value = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
+ if ((value & 0x00800000) != 0)
+ value |= unchecked((int)0xFF000000);
+ return value;
+ }
+
+ private static string ReadFourCc(BinaryReader reader)
+ {
+ return Encoding.ASCII.GetString(reader.ReadBytes(4));
+ }
+
+ private static void WriteFourCc(BinaryWriter writer, string fourCc)
+ {
+ writer.Write(Encoding.ASCII.GetBytes(fourCc));
+ }
+ }
+}
\ No newline at end of file
diff --git a/LiveSPICE.sln b/LiveSPICE.sln
index 6bf97481..da7a9739 100644
--- a/LiveSPICE.sln
+++ b/LiveSPICE.sln
@@ -36,6 +36,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.github\workflows\test.yml = .github\workflows\test.yml
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{FF178A26-25B0-462A-9927-4FD42C710136}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -90,6 +92,14 @@ Global
{ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECCE5E4E-730F-4A2D-A772-3AC922D9ACD2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {51F8BCEC-5C29-46BA-8448-06B516715840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {51F8BCEC-5C29-46BA-8448-06B516715840}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {51F8BCEC-5C29-46BA-8448-06B516715840}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {51F8BCEC-5C29-46BA-8448-06B516715840}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FF178A26-25B0-462A-9927-4FD42C710136}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FF178A26-25B0-462A-9927-4FD42C710136}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/LiveSPICEVst/SimulationProcessor.cs b/LiveSPICEVst/SimulationProcessor.cs
index 03d56442..74e8c81f 100644
--- a/LiveSPICEVst/SimulationProcessor.cs
+++ b/LiveSPICEVst/SimulationProcessor.cs
@@ -272,8 +272,7 @@ void UpdateSimulation(bool rebuild)
{
try
{
- Analysis analysis = circuit.Analyze();
- TransientSolution ts = TransientSolution.Solve(analysis, (Real)1 / (sampleRate * oversample));
+ TransientSolution ts = AudioSimulationFactory.Solve(circuit, sampleRate, oversample);
lock (sync)
{
@@ -281,45 +280,14 @@ void UpdateSimulation(bool rebuild)
{
if (rebuild)
{
- Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault();
-
- if (inputExpression == null)
- {
- simulationUpdateException = new NotSupportedException("Circuit has no inputs.");
- }
- else
- {
- IEnumerable speakers = circuit.Components.OfType();
-
- Expression outputExpression = 0;
-
- // Output is voltage drop across the speakers
- foreach (Speaker speaker in speakers)
- {
- outputExpression += speaker.Out;
- }
-
- if (outputExpression.EqualsZero())
- {
- simulationUpdateException = new NotSupportedException("Circuit has no speaker outputs.");
- }
- else
- {
- simulation = new Simulation(ts)
- {
- Oversample = oversample,
- Iterations = iterations,
- Input = new[] { inputExpression },
- Output = new[] { outputExpression }
- };
- }
- }
+ simulation = AudioSimulationFactory.Create(circuit, ts, oversample, iterations);
}
else
{
simulation.Solution = ts;
- clock = id;
}
+
+ clock = id;
}
}
}
diff --git a/README.md b/README.md
index c2a894db..6aee021b 100644
--- a/README.md
+++ b/README.md
@@ -16,3 +16,36 @@ To clone the LiveSPICE repo, run the following commands:
```bash
git clone --recursive https://github.com/dsharlet/LiveSPICE.git LiveSPICE
```
+
+If you cloned without `--recursive`, initialize the submodules before building:
+
+```bash
+git submodule update --init --recursive
+```
+
+Headless Linux
+--------------
+
+The `LiveSPICE.Headless` project provides a cross-platform console host for the simulation core. It reuses the same input/output wiring logic as the VST path, while the WPF UI and VST projects remain Windows-only.
+
+To build the headless runner:
+
+```bash
+dotnet build LiveSPICE.Headless/LiveSPICE.Headless.csproj
+```
+
+To run a simple Linux smoke test:
+
+```bash
+dotnet run --project LiveSPICE.Headless/LiveSPICE.Headless.csproj "Tests/Circuits/Passive 1stOrder Highpass RC.schx"
+```
+
+To process a real WAV input through an example pedal schematic:
+
+```bash
+dotnet run --project LiveSPICE.Headless/LiveSPICE.Headless.csproj "Tests/Examples/MXR Distortion +.schx" \
+ --input-wav "/path/to/input.wav" \
+ --output-wav "/path/to/output.wav"
+```
+
+The runner accepts optional overrides for `--input-wav`, `--output-wav`, `--sample-rate`, `--oversample`, `--iterations`, `--samples`, `--batch-size`, `--frequency`, and `--amplitude`.