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`.