diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01e2408a..4bd1cc9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,3 +76,9 @@ jobs: dotnet run -c Release --no-build -- render \ --schematic "../Tests/Examples/Passive 1stOrder Lowpass RC.schx" \ --input /tmp/tone.wav --output /tmp/out.wav + # The Avalonia GUI and its tests run headless (Avalonia.Headless.XUnit), so they work on + # runners with no display or audio hardware. + - name: Build Avalonia GUI + run: dotnet build LiveSPICE.Linux.sln -c Release + - name: Run Avalonia tests + run: dotnet test LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj -c Release --no-build 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 cad3f31f..56e2b4da 100644 --- a/Circuit/Circuit.csproj +++ b/Circuit/Circuit.csproj @@ -24,6 +24,8 @@ + + \ No newline at end of file diff --git a/Circuit/Components/Resistor.cs b/Circuit/Components/Resistor.cs index fd255f30..a97a983f 100644 --- a/Circuit/Components/Resistor.cs +++ b/Circuit/Components/Resistor.cs @@ -59,8 +59,8 @@ protected internal override void LayoutSymbol(SymbolLayout Sym) Draw(Sym, 0, -16, 16, 7); - Sym.DrawText(() => Name, new Coord(6, 0), Alignment.Near, Alignment.Center); - Sym.DrawText(() => resistance.ToString(), new Coord(-6, 0), Alignment.Far, Alignment.Center); + Sym.DrawText(() => Name, new Coord(14, 0), Alignment.Near, Alignment.Center); + Sym.DrawText(() => resistance.ToString(), new Coord(-14, 0), Alignment.Far, Alignment.Center); } } } diff --git a/GUI_PORT_PLAN.md b/GUI_PORT_PLAN.md new file mode 100644 index 00000000..62a99a5b --- /dev/null +++ b/GUI_PORT_PLAN.md @@ -0,0 +1,189 @@ +# LiveSPICE Cross-Platform GUI Port Plan + +## Current state + +The local `linux` branch already has a working non-Windows simulation path through `LiveSPICE.Headless`. That branch builds a console runner around the shared `Circuit` library and `Circuit/AudioSimulationFactory.cs`, and the README documents Linux smoke tests and WAV processing. + +The remaining portability gap is the GUI. The standalone editor is a WPF Windows desktop app in `LiveSPICE/LiveSPICE.csproj`, and the VST UI is also WPF-based in `LiveSPICEVst/LiveSPICEVst.csproj` through `AudioPlugSharpWPF`. Both target `net*-windows`, enable WPF/Windows Forms, and depend on Windows-only UI concepts such as `Microsoft.Win32` dialogs, WPF commands, WPF drawing, AvalonDock, and WinMM/ASIO-backed audio configuration. + +## Branch strategy + +Create a second branch from the local Linux branch so the headless Linux work stays intact and the GUI port can move independently: + +```bash +git switch linux +git pull --ff-only origin linux +git switch -c linux-gui-port +``` + +Before running those commands locally, review the current dirty worktree. At the time this plan was written, `.gitignore` was modified and `.codacy/`, `PR_DESCRIPTION.md`, and `out/` were untracked. Either commit, stash, or intentionally carry those changes before switching branches. + +## Recommended UI approach + +Use Avalonia as the default open-source port target for the standalone GUI. + +Avalonia is the closest fit because it keeps a XAML/C# mental model, supports Windows, macOS, and Linux, and has direct replacements for many WPF primitives used by LiveSPICE: windows, menus, command bindings, layout panels, data binding, custom controls, pointer input, drawing contexts, file pickers, and theming. + +Do not start with Electron unless the goal changes to a web-first shell. The existing editor is deeply tied to C# circuit objects, custom schematic rendering, and direct manipulation of schematic elements, so an Electron port would add an interprocess API and rewrite most UI logic instead of adapting it. + +Treat Avalonia XPF as an optional commercial shortcut, not the default plan. It may run more existing WPF XAML with fewer edits, but it adds licensing and still leaves platform-specific APIs, WPF-only dependencies, and audio/plugin details to solve. + +## Architecture target + +Keep the existing simulation core and serialization shared: + +- `Circuit` remains the cross-platform schematic, component, solver, and simulation library. +- `ComputerAlgebra` and `Util` remain shared support libraries. +- `LiveSPICE.Headless` remains the Linux command-line validation target. +- A new Avalonia app, tentatively `LiveSPICE.Avalonia`, hosts the cross-platform editor UI. +- The existing WPF `LiveSPICE` app stays buildable on Windows until the Avalonia app reaches feature parity. + +Avoid trying to retarget `LiveSPICE/LiveSPICE.csproj` in place at first. A sibling project makes it possible to port control by control while preserving the Windows app as a reference implementation. + +## Porting phases + +### Phase 1: Establish the Avalonia shell + +Create `LiveSPICE.Avalonia` targeting `net8.0` or newer without a `-windows` target framework. Add it to `LiveSPICE.sln` and reference `Circuit`, `ComputerAlgebra`, and `Util`. + +Build the first shell with: + +- main window +- menu bar and toolbar +- status bar +- open/save file pickers for `.schx` +- single-document schematic viewer +- basic settings path under the user's platform-appropriate application data directory + +Defer docking, MRU polish, audio config, and simulation UI until a schematic can be loaded and rendered. + +### Phase 2: Port schematic rendering + +Port `SchematicControls` concepts to Avalonia rather than trying to reuse WPF controls directly. + +The core work is replacing WPF rendering types in `SchematicControls`: + +- `Control.OnRender(DrawingContext)` becomes Avalonia custom control rendering. +- `System.Windows.Point`, `Vector`, `Matrix`, `Rect`, `Size` become Avalonia equivalents. +- WPF `Pen`, `Brushes`, `FormattedText`, geometry, and text APIs become Avalonia drawing APIs. +- Tooltips and hit testing need Avalonia pointer/event equivalents. + +Start with read-only rendering of symbols and wires. Use the existing `Circuit.SymbolLayout` data as the model, because it is already UI-framework neutral. Once rendering is correct, add selection highlighting, terminal tooltips, and zoom/pan. + +### Phase 3: Port schematic editing + +Port `SchematicControl`, `SchematicViewer`, and `SchematicEditor` behavior into Avalonia equivalents. + +Primary features: + +- grid snapping and coordinate conversion +- selection rectangle and multi-select +- move, wire, symbol, and probe tools +- clipboard copy/paste using XML serialization +- undo/redo through `EditStack` +- save/save-as and external modification detection +- component library insertion + +Keep edit operations model-driven. The existing `AddElements`, `RemoveElements`, `PropertyEdit`, and schematic serialization should continue to be the behavioral source of truth. + +### Phase 4: Replace WPF-only desktop dependencies + +Replace AvalonDock with a simpler first-pass layout: left component list, right property panel, central tabbed documents. Add a richer docking library only after the editor is functional. + +Replace `DotNetProjects.Extended.Wpf.Toolkit` property grid usage with either: + +- a small LiveSPICE-specific property editor generated from browsable component properties, or +- an Avalonia-compatible property grid package if one proves mature enough. + +Prefer the small custom property editor initially. LiveSPICE mostly needs predictable editing of component values, not a general-purpose WPF toolkit clone. + +### Phase 5: Audio on Linux and macOS + +For the standalone GUI, keep audio driver selection behind the existing `Audio.Driver` abstraction. The current GUI references `Asio` and `WaveAudio`; `WaveAudio` is WinMM-only and `Asio` is Windows-oriented. + +Implement new drivers as separate assemblies so `Audio.Driver.Drivers` can discover them like the existing drivers: + +- `JackAudio` for Linux JACK, using a maintained .NET binding or a small native interop layer. +- `CoreAudio` for macOS only after the GUI can load, edit, and render schematics. + +Initial Linux GUI milestones can run without real-time audio by reusing the headless WAV path. Real-time JACK should be a later milestone because it has native library, callback-thread, buffer-size, and packaging concerns. + +### Phase 6: Plugin UI strategy + +Handle the VST UI separately from the standalone editor. + +AudioPlugSharp itself is intended to support non-Windows audio/plugin targets, but this repository's plugin UI currently inherits from `AudioPluginWPF` and references `AudioPlugSharpWPF`. Public AudioPlugSharp docs emphasize built-in WPF UI support, so cross-platform plugin UI support needs verification before committing to an implementation. + +Investigate in this order: + +1. Check whether current AudioPlugSharp has a non-WPF UI base class or host-embeddable native view API. +2. Build a minimal no-editor plugin on Linux/macOS using the existing `SimulationProcessor` logic. +3. Build a tiny cross-platform plugin editor proof of concept before porting LiveSPICE's plugin UI. +4. If AudioPlugSharp cannot host Avalonia directly, keep the plugin editor Windows-only for the first GUI branch and ship the standalone Avalonia editor plus headless/audio processing on Linux. + +The plugin UI is much smaller than the full desktop app, but it has harder host-embedding constraints. Do not let it block the standalone GUI port. + +## Milestones and validation + +### Milestone A: Branch and baseline + +- Create `linux-gui-port` from `linux`. +- Confirm `dotnet build Circuit/Circuit.csproj` passes. +- Confirm `dotnet build LiveSPICE.Headless/LiveSPICE.Headless.csproj` passes. +- Run the README headless smoke test against `Tests/Circuits/Passive 1stOrder Highpass RC.schx`. + +### Milestone B: Avalonia app opens a schematic + +- Create `LiveSPICE.Avalonia`. +- Load a `.schx` file. +- Display schematic metadata or a placeholder document tab. +- Save settings without Windows registry or WPF settings dependencies. + +### Milestone C: Read-only schematic renderer + +- Render wires, symbols, text, terminals, and schematic grid. +- Validate against known examples under `Tests/Examples`. +- Add screenshot comparison fixtures if practical. + +### Milestone D: Basic editor parity + +- Select, move, add, delete, undo, redo, copy, paste, save, and save-as. +- Component library and property editor are usable. +- Existing `.schx` files round-trip without serialization changes. + +### Milestone E: Simulation workflow + +- Run simulation from the Avalonia UI using `AudioSimulationFactory`. +- Display probe/scope output or an initial non-real-time render result. +- Keep Linux validation available without requiring JACK. + +### Milestone F: Real-time audio + +- Add JACK driver assembly for Linux. +- Validate callback stability, buffer sizes, sample rate selection, xrun behavior, and clean shutdown. +- Add CoreAudio driver or document that macOS support currently depends on plugin-host processing. + +### Milestone G: Plugin investigation + +- Prove whether AudioPlugSharp can host a non-WPF editor on Linux/macOS. +- Decide between Avalonia plugin UI, no-editor plugin, or Windows-only plugin UI for the first release. + +## Technical risks + +- Custom drawing is the largest standalone GUI task. `SymbolControl` and `WireControl` depend directly on WPF drawing APIs. +- Docking and property grid packages are WPF-specific and should be replaced, not ported first. +- File dialogs, clipboard, tooltips, command routing, keyboard modifiers, and mouse capture all need Avalonia-specific implementations. +- Real-time audio should not run on UI abstractions. Keep audio callback code isolated from Avalonia dispatching. +- Plugin UI portability may be constrained by the plugin host API more than by Avalonia itself. +- Packaging needs per-platform handling for native audio libraries, app icons, file associations, and macOS signing/notarization. + +## First implementation checklist + +1. Create `linux-gui-port` from `linux` after cleaning or stashing unrelated worktree changes. +2. Add `LiveSPICE.Avalonia` with a minimal window and solution entry. +3. Add a tiny `SchematicDocumentViewModel` that loads `Circuit.Schematic` from disk. +4. Port read-only drawing into a new Avalonia schematic canvas. +5. Add open-file flow and zoom/pan. +6. Add focused editor tools and property editing. +7. Add non-real-time simulation validation before JACK/CoreAudio. +8. Investigate AudioPlugSharp non-WPF editor support in a separate proof-of-concept branch or folder. \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/AppSettingsTests.cs b/LiveSPICE.Avalonia.Tests/AppSettingsTests.cs new file mode 100644 index 00000000..f06425be --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/AppSettingsTests.cs @@ -0,0 +1,59 @@ +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class AppSettingsTests +{ + [Fact] + public void SaveAndLoadRoundTripsWindowRecentAndAudioSettings() + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "settings.json"); + AppSettings settings = new AppSettings + { + WindowWidth = 1400, + WindowHeight = 900, + AudioDriver = "Virtual Audio", + AudioDevice = "Managed loopback", + AudioInputs = new List { "Input 1" }, + AudioOutputs = new List { "Output 1" } + }; + settings.MarkUsed("Tests/Examples/MXR Phase 90.schx"); + + settings.Save(path); + AppSettings loaded = AppSettings.Load(path); + + Assert.Equal(1400, loaded.WindowWidth); + Assert.Equal(900, loaded.WindowHeight); + Assert.Equal("Virtual Audio", loaded.AudioDriver); + Assert.Equal("Managed loopback", loaded.AudioDevice); + Assert.Equal("Input 1", Assert.Single(loaded.AudioInputs)); + Assert.Equal("Output 1", Assert.Single(loaded.AudioOutputs)); + Assert.EndsWith("MXR Phase 90.schx", Assert.Single(loaded.RecentFiles)); + } + + [Fact] + public void MarkUsedDeduplicatesAndCapsRecentFiles() + { + AppSettings settings = new AppSettings(); + + for (int i = 0; i < 25; i++) + settings.MarkUsed($"file-{i}.schx"); + settings.MarkUsed("file-10.schx"); + + Assert.Equal(20, settings.RecentFiles.Count); + Assert.EndsWith("file-10.schx", settings.RecentFiles[0]); + Assert.Equal(settings.RecentFiles.Count, settings.RecentFiles.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public void ExistingRecentFilesFiltersMissingEntries() + { + string existing = Path.GetTempFileName(); + AppSettings settings = new AppSettings(); + settings.MarkUsed("missing-file.schx"); + settings.MarkUsed(existing); + + Assert.Equal(existing, Assert.Single(settings.ExistingRecentFiles())); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs b/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..0dd02ca9 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/AssemblyInfo.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Headless; +using LiveSPICE.Avalonia; +using LiveSPICE.Avalonia.Tests; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +// One headless Avalonia application for the whole test process. AppBuilder.Setup can only run +// once per process, so per-class setup helpers ("Setup was already called") and desktop +// SetupWithoutStarting (no dispatcher loop, so Dispatcher.UIThread.Invoke hangs) both fail when +// the suite runs together. Tests that touch controls or windows use [AvaloniaFact], which runs +// them on the headless dispatcher thread. +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace LiveSPICE.Avalonia.Tests; + +public class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} diff --git a/LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs b/LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs new file mode 100644 index 00000000..cf562be2 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/AudioDriverDiscoveryTests.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Runtime.InteropServices; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public class AudioDriverDiscoveryTests +{ + [Fact] + public void CoreAudioDriverIsDiscoveredOnMacOS() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; + + // The driver must appear even with no audio hardware (CI runners), so assert on the + // driver, not its devices. + var drivers = AvaloniaAudioDrivers.Available(); + Assert.Contains(drivers, d => d.Name == "Core Audio"); + } + + [Fact] + public void VirtualDriverIsAlwaysAvailable() + { + var drivers = AvaloniaAudioDrivers.Available(); + Assert.Contains(drivers, d => d.Devices.Any(dev => dev.OutputChannels.Length > 0)); + } +} diff --git a/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj new file mode 100644 index 00000000..a0fc0436 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj @@ -0,0 +1,28 @@ + + + net8.0 + enable + enable + LiveSPICE.Avalonia.Tests + false + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs b/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs new file mode 100644 index 00000000..5196577e --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/MenuOpenPathTests.cs @@ -0,0 +1,34 @@ +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class MenuOpenPathTests +{ + [Fact] + public void OpenPathDecodesFileUriWithSpaces() + { + Assert.Equal("/tmp/MXR Phase 90.schx", MainWindow.OpenPath(null, new Uri("file:///tmp/MXR%20Phase%2090.schx"))); + } + + [Fact] + public void OpenPathPrefersLocalPathWhenAvailable() + { + Assert.Equal("/chosen/MXR Phase 90.schx", MainWindow.OpenPath("/chosen/MXR Phase 90.schx", new Uri("file:///tmp/MXR%20Phase%2090.schx"))); + } + + [Fact] + public void UntouchedStartupDocumentCanBeReplacedByFirstOpen() + { + Assert.True(MainWindow.IsUntouchedStartupDocument(SchematicDocument.New())); + } + + [Fact] + public void DirtyStartupDocumentIsNotReplacedByFirstOpen() + { + SchematicDocument document = SchematicDocument.New(); + document.MarkDirty(); + + Assert.False(MainWindow.IsUntouchedStartupDocument(document)); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/PluginPortTests.cs b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs new file mode 100644 index 00000000..84039424 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/PluginPortTests.cs @@ -0,0 +1,296 @@ +using AudioPlugSharp; +using System.IO; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using LiveSPICE.Avalonia; +using LiveSPICE.PluginCore; +using LiveSPICE.PluginLinux; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public class PluginPortTests +{ + [Fact] + public void LinuxPluginInitializesMonoPorts() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + + plugin.Initialize(); + + Assert.Single(plugin.InputPorts); + Assert.Single(plugin.OutputPorts); + Assert.Equal(EAudioChannelConfiguration.Mono, plugin.InputPorts[0].ChannelConfiguration); + Assert.Equal(EAudioChannelConfiguration.Mono, plugin.OutputPorts[0].ChannelConfiguration); + Assert.True(plugin.HasUserInterface); + Assert.Equal(700u, plugin.EditorWidth); + Assert.Equal(420u, plugin.EditorHeight); + } + + [Fact] + public void LinuxPluginStartsWithoutHardcodedSchematicByDefault() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + + Assert.Null(plugin.SimulationProcessor.Schematic); + Assert.Empty(plugin.SchematicPath); + Assert.DoesNotContain("LiveSPICE.PluginLinux.DefaultSchematic.schx", typeof(LiveSPICELinuxPlugin).Assembly.GetManifestResourceNames()); + } + + [AvaloniaFact] + public void LinuxPluginCreatesAvaloniaEditorBoundToProcessor() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + PluginEditorWindow editor = RunOnUiThread(plugin.CreateEditorWindow); + try + { + Assert.Equal("Load Schematic", editor.TestLoadedText); + Assert.Equal(0, editor.TestControlPanelCount); + Assert.Equal(0, editor.TestOverlayControlCount); + + plugin.LoadSchematic(FindFixture("Tests/Circuits/59 Bassman Preamp.schx")); + editor.LoadSchematic(plugin.SchematicPath); + + Assert.Equal(plugin.SimulationProcessor.SchematicName, editor.TestLoadedText); + Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestControlPanelCount); + Assert.Equal(plugin.SimulationProcessor.InteractiveComponents.Count, editor.TestOverlayControlCount); + } + finally + { + CloseOnUiThread(editor); + } + } + + [AvaloniaFact] + public void PluginEditorSettingsUpdateSharedProcessor() + { + SimulationProcessor processor = new SimulationProcessor(); + PluginEditorWindow editor = RunOnUiThread(() => new PluginEditorWindow(processor)); + try + { + editor.TestSelectedOversample = 8; + editor.TestSelectedIterations = 32; + + Assert.Equal(8, processor.Oversample); + Assert.Equal(32, processor.Iterations); + } + finally + { + CloseOnUiThread(editor); + } + } + + [Fact] + public void PluginProgramParametersRoundTripProcessorSettings() + { + SimulationProcessor processor = new SimulationProcessor + { + Oversample = 4, + Iterations = 16, + }; + + PluginProgramParameters parameters = PluginProgramParameters.FromProcessor(processor); + SimulationProcessor restored = new SimulationProcessor(); + parameters.ApplyTo(restored); + + Assert.Equal(4, restored.Oversample); + Assert.Equal(16, restored.Iterations); + } + + [Fact] + public void LinuxPluginStateRoundTripsProcessorSettings() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + string schematicPath = FindFixture("Tests/Circuits/59 Bassman Preamp.schx"); + plugin.LoadSchematic(schematicPath); + plugin.SimulationProcessor.Oversample = 8; + plugin.SimulationProcessor.Iterations = 32; + SetDistinctControlValues(plugin.SimulationProcessor); + + byte[] state = plugin.SaveState(); + LiveSPICELinuxPlugin restored = new LiveSPICELinuxPlugin(); + restored.RestoreState(state); + + Assert.Equal(schematicPath, restored.SchematicPath); + Assert.Equal(8, restored.SimulationProcessor.Oversample); + Assert.Equal(32, restored.SimulationProcessor.Iterations); + AssertControlValuesEqual(plugin.SimulationProcessor, restored.SimulationProcessor); + } + + [Fact] + public void PluginEditorCreatesOverlayControlsForInteractiveSchematic() + { + SimulationProcessor processor = new SimulationProcessor(); + string schematicPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Tests/Circuits/59 Bassman Preamp.schx")); + processor.LoadSchematic(schematicPath); + + Assert.NotEmpty(processor.InteractiveComponents); + Assert.All(processor.InteractiveComponents, wrapper => + Assert.Contains(processor.Schematic!.Symbols, symbol => PluginEditorWindow.WrapperMatchesSymbol(wrapper, symbol))); + } + + [Fact] + public void SimulationProcessorPassesThroughWhenNoSchematicIsLoaded() + { + SimulationProcessor processor = new SimulationProcessor(); + double[][] input = new[] { Enumerable.Range(0, 64).Select(i => Math.Sin(i / 8.0)).ToArray() }; + double[][] output = new[] { new double[64] }; + + processor.RunSimulation(input, output, output[0].Length); + + Assert.Equal(input[0], output[0]); + } + + [Fact] + public void SimulationProcessorProcessesLoadedRcSchematic() + { + SimulationProcessor processor = new SimulationProcessor + { + SampleRate = 48000, + Oversample = 4, + Iterations = 8, + }; + processor.LoadSchematic(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + double[][] input = new[] { Enumerable.Range(0, 512).Select(i => Math.Sin(2 * Math.PI * 440 * i / 48000)).ToArray() }; + double[][] output = new[] { new double[512] }; + + RunUntilReady(processor, input, output, output[0].Length); + + Assert.Contains(output[0], i => Math.Abs(i) > 1e-9); + Assert.NotEqual(input[0], output[0]); + Assert.DoesNotContain(output[0], double.IsNaN); + } + + [AvaloniaFact] + public void PluginLoadsMxrPhase90WithControlsAndProducesStableAudio() + { + LiveSPICELinuxPlugin plugin = new LiveSPICELinuxPlugin(); + string schematicPath = FindFixture("Tests/Examples/MXR Phase 90.schx"); + plugin.LoadSchematic(schematicPath); + + PotWrapper[] pots = plugin.SimulationProcessor.InteractiveComponents.OfType().ToArray(); + Assert.Equal(new[] { "Speed", "Trimmer" }, pots.Select(i => i.Name).OrderBy(i => i).ToArray()); + + PluginEditorWindow editor = RunOnUiThread(plugin.CreateEditorWindow); + try + { + editor.LoadSchematic(schematicPath); + Assert.Equal("MXR Phase 90", editor.TestLoadedText); + Assert.Equal(2, editor.TestControlPanelCount); + Assert.Equal(2, editor.TestOverlayControlCount); + } + finally + { + CloseOnUiThread(editor); + } + + double[][] input = new[] { Enumerable.Range(0, 1024).Select(i => 0.1 * Math.Sin(2 * Math.PI * 220 * i / 48000)).ToArray() }; + double[][] output = new[] { new double[1024] }; + plugin.SimulationProcessor.SampleRate = 48000; + plugin.SimulationProcessor.Oversample = 4; + plugin.SimulationProcessor.Iterations = 8; + + RunUntilReady(plugin.SimulationProcessor, input, output, output[0].Length); + + Assert.Contains(output[0], i => Math.Abs(i) > 1e-9); + Assert.DoesNotContain(output[0], double.IsNaN); + } + + private static void RunUntilReady(SimulationProcessor processor, double[][] input, double[][] output, int length) + { + Exception? lastException = null; + for (int attempt = 0; attempt < 20; attempt++) + { + Array.Clear(output[0]); + try + { + processor.RunSimulation(input, output, length); + if (output[0].Any(i => Math.Abs(i) > 1e-12) && !output[0].SequenceEqual(input[0])) + return; + } + catch (NullReferenceException ex) + { + lastException = ex; + } + Thread.Sleep(50); + } + + if (lastException != null) + throw lastException; + } + + private static void SetDistinctControlValues(SimulationProcessor processor) + { + int position = 0; + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + { + switch (wrapper) + { + case PotWrapper potWrapper: + potWrapper.PotValue = 0.1 + position * 0.03; + break; + case DoubleThrowWrapper doubleThrowWrapper: + doubleThrowWrapper.Engaged = position % 2 == 0; + break; + case MultiThrowWrapper multiThrowWrapper: + multiThrowWrapper.Position = position % 3; + break; + } + position++; + } + } + + private static void AssertControlValuesEqual(SimulationProcessor expected, SimulationProcessor actual) + { + Assert.Equal(expected.InteractiveComponents.Count, actual.InteractiveComponents.Count); + for (int index = 0; index < expected.InteractiveComponents.Count; index++) + { + IComponentWrapper expectedWrapper = expected.InteractiveComponents[index]; + IComponentWrapper actualWrapper = actual.InteractiveComponents[index]; + Assert.Equal(expectedWrapper.Name, actualWrapper.Name); + switch (expectedWrapper) + { + case PotWrapper expectedPot: + PotWrapper actualPot = Assert.IsType(actualWrapper); + Assert.Equal(expectedPot.PotValue, actualPot.PotValue, 12); + break; + case DoubleThrowWrapper expectedSwitch: + DoubleThrowWrapper actualDoubleThrow = Assert.IsType(actualWrapper); + Assert.Equal(expectedSwitch.Engaged, actualDoubleThrow.Engaged); + break; + case MultiThrowWrapper expectedSwitch: + MultiThrowWrapper actualMultiThrow = Assert.IsType(actualWrapper); + Assert.Equal(expectedSwitch.Position, actualMultiThrow.Position); + break; + } + } + } + + private static T RunOnUiThread(Func action) + { + return Dispatcher.UIThread.CheckAccess() + ? action() + : Dispatcher.UIThread.Invoke(action); + } + + private static void CloseOnUiThread(PluginEditorWindow editor) + { + if (Dispatcher.UIThread.CheckAccess()) + editor.Close(); + else + Dispatcher.UIThread.Invoke(editor.Close); + } + + 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.Tests/SchematicCanvasInteractionTests.cs b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs new file mode 100644 index 00000000..88d05050 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/SchematicCanvasInteractionTests.cs @@ -0,0 +1,189 @@ +using Circuit; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class SchematicCanvasInteractionTests +{ + [AvaloniaFact] + public void ClickSelectsSymbol() + { + TestContext context = TestContext.Load(); + Symbol symbol = context.FirstSymbol; + + Assert.True(context.Canvas.TestClick(Center(symbol))); + Assert.Same(symbol.Component, context.Canvas.SelectedObjects.Single()); + } + + [AvaloniaFact] + public void ControlClickTogglesSelection() + { + TestContext context = TestContext.Load(); + Symbol first = context.FirstSymbol; + Symbol second = context.SecondSymbol; + + context.Canvas.TestClick(Center(first)); + context.Canvas.TestClick(Center(second), control: true); + Assert.Equal(2, context.Canvas.SelectedObjects.Count); + + context.Canvas.TestClick(Center(first), control: true); + Assert.Single(context.Canvas.SelectedObjects); + } + + [AvaloniaFact] + public void DragSelectedSymbolRecordsOneUndo() + { + TestContext context = TestContext.Load(); + Symbol symbol = context.FirstSymbol; + Coord before = symbol.Position; + Coord from = Center(symbol); + Coord to = from + new Coord(30, 0); + + context.Canvas.TestClick(from); + context.Canvas.TestDragSelected(from, to); + + Assert.Equal(before + new Coord(30, 0), symbol.Position); + Assert.True(context.Document.CanUndo); + context.Document.Undo(); + Assert.Equal(before, symbol.Position); + context.Document.Redo(); + Assert.Equal(before + new Coord(30, 0), symbol.Position); + } + + [AvaloniaFact] + public void RectangleDragSelectsMultipleElements() + { + TestContext context = TestContext.Load(); + Coord lower = context.Document.Schematic.LowerBound - new Coord(20, 20); + Coord upper = context.Document.Schematic.UpperBound + new Coord(20, 20); + + context.Canvas.TestDragSelect(lower, upper); + + Assert.True(context.Canvas.SelectedObjects.Count >= 2); + } + + [AvaloniaFact] + public void DeleteSelectionIsUndoable() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Elements.Count(); + + context.Canvas.TestClick(Center(context.FirstSymbol)); + context.Canvas.DeleteSelection(); + + Assert.Equal(before - 1, context.Document.Schematic.Elements.Count()); + context.Document.Undo(); + Assert.Equal(before, context.Document.Schematic.Elements.Count()); + } + + [AvaloniaFact] + public void WireClicksCreateUndoableWire() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Wires.Count(); + Coord a = new Coord(-120, -80); + Coord b = new Coord(-60, -80); + + Assert.False(context.Canvas.TestWireClick(a)); + Assert.True(context.Canvas.TestWireClick(b)); + + Assert.Equal(before + 1, context.Document.Schematic.Wires.Count()); + Assert.True(context.Document.CanUndo); + context.Document.Undo(); + Assert.Equal(before, context.Document.Schematic.Wires.Count()); + } + + [AvaloniaFact] + public void CopyPasteDuplicatesSelection() + { + TestContext context = TestContext.Load(); + int before = context.Document.Schematic.Elements.Count(); + + context.Canvas.TestClick(Center(context.FirstSymbol)); + string? xml = context.Canvas.CopySelectionXml(); + + Assert.False(string.IsNullOrWhiteSpace(xml)); + Assert.True(context.Canvas.PasteSelectionXml(xml!)); + Assert.Equal(before + 1, context.Document.Schematic.Elements.Count()); + Assert.Single(context.Canvas.SelectedObjects); + context.Document.Undo(); + Assert.Equal(before, context.Document.Schematic.Elements.Count()); + } + + [AvaloniaFact] + public void DrawTransformMatchesSpeakerTerminals() + { + TestContext context = TestContext.Load(); + Symbol speaker = context.Document.Schematic.Symbols.Single(i => i.Component is Speaker); + + foreach (Terminal terminal in speaker.Terminals) + Assert.Equal(speaker.MapTerminal(terminal), SchematicCanvas.TestTransformPoint(speaker, speaker.Component.LayoutSymbol().MapTerminal(terminal))); + } + + [AvaloniaFact] + public void DrawTransformMatchesGroundTerminal() + { + Symbol ground = new Symbol(new Ground()) { Position = new Coord(120, -40), Rotation = 1 }; + Terminal terminal = ground.Terminals.Single(); + + Assert.Equal(ground.MapTerminal(terminal), SchematicCanvas.TestTransformPoint(ground, ground.Component.LayoutSymbol().MapTerminal(terminal))); + } + + [AvaloniaFact] + public void ResistorValueAndNameTextAreOutsideBody() + { + SymbolLayout layout = new Resistor().LayoutSymbol(); + SymbolLayout.Text name = layout.Texts.Single(i => i.String == "R1"); + SymbolLayout.Text value = layout.Texts.Single(i => i.String.Contains("Ω")); + + Assert.True(name.x.x >= 12); + Assert.True(value.x.x <= -12); + } + + private static Coord Center(Element element) + { + return (element.LowerBound + element.UpperBound) / 2; + } + + private sealed class TestContext + { + private TestContext(SchematicDocument document, SchematicCanvas canvas) + { + Document = document; + Canvas = canvas; + } + + public SchematicDocument Document { get; } + + public SchematicCanvas Canvas { get; } + + public Symbol FirstSymbol => Document.Schematic.Symbols.First(); + + public Symbol SecondSymbol => Document.Schematic.Symbols.Skip(1).First(); + + public static TestContext Load() + { + SchematicDocument document = SchematicDocument.Open(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + SchematicCanvas canvas = Dispatcher.UIThread.CheckAccess() + ? new SchematicCanvas { Document = document } + : Dispatcher.UIThread.Invoke(() => new SchematicCanvas { Document = document }); + return new TestContext(document, canvas); + } + + 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); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs new file mode 100644 index 00000000..7cb14129 --- /dev/null +++ b/LiveSPICE.Avalonia.Tests/SimulationAndAudioTests.cs @@ -0,0 +1,275 @@ +using Circuit; +using LiveSPICE.Avalonia; +using Xunit; + +namespace LiveSPICE.Avalonia.Tests; + +public sealed class SimulationAndAudioTests +{ + [Fact] + public void AudioSimulationFactoryRunsPassiveHighpass() + { + Circuit.Circuit circuit = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")).Build(); + Simulation simulation = AudioSimulationFactory.Create(circuit, 48000, 4, 8); + double[] input = Enumerable.Range(0, 512).Select(i => Math.Sin(2 * Math.PI * 440 * i / 48000)).ToArray(); + double[] output = new double[input.Length]; + + simulation.Run(input.Length, new[] { input }, new[] { output }); + + Assert.Contains(output, i => Math.Abs(i) > 1e-9); + Assert.DoesNotContain(output, double.IsNaN); + } + + [Fact] + public void AudioSimulationFactoryRejectsCircuitWithoutInput() + { + Circuit.Circuit circuit = new Circuit.Circuit(); + + Assert.Throws(() => AudioSimulationFactory.Create(circuit, 48000, 4, 8)); + } + + [Fact] + public void VirtualAudioDriverProvidesLoopbackDeviceAndChannels() + { + VirtualAudioDriver driver = new VirtualAudioDriver(); + Audio.Device device = driver.Devices.Single(); + + Assert.Equal("LiveSPICE Virtual Audio", driver.Name); + Assert.Equal("LiveSPICE Virtual Audio", driver.ToString()); + Assert.Equal("Managed loopback", device.Name); + Assert.Equal("Managed loopback", device.ToString()); + Assert.Single(device.InputChannels); + Assert.Single(device.OutputChannels); + } + + [Fact] + public void LinuxAudioDiscoveryFiltersNonAudioPorts() + { + LinuxAudioDriver driver = new LinuxAudioDriver(); + + Assert.Equal("PipeWire/JACK", driver.Name); + Assert.Equal("PipeWire/JACK", driver.ToString()); + foreach (Audio.Device device in driver.Devices) + { + Assert.Equal("PipeWire/JACK port graph", device.Name); + Assert.Equal("PipeWire/JACK port graph", device.ToString()); + Assert.DoesNotContain(device.InputChannels.Concat(device.OutputChannels), i => i.Name.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase)); + } + } + + [Fact] + public void AvaloniaAudioDriversListsLinuxAndVirtualDriversOnce() + { + Audio.Driver[] drivers = AvaloniaAudioDrivers.Available().ToArray(); + string[] names = drivers.Select(i => i.Name).ToArray(); + + Assert.Contains("PipeWire/JACK", names); + Assert.Contains("LiveSPICE Virtual Audio", names); + Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public void LinuxAudioDiscoveryPrefersJackPortNamesOverRawPipeWireNodes() + { + string[] ports = LinuxAudioDiscovery.PreferredPorts( + new[] { "Built-in Audio Analog Stereo:capture_FL", "Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0" }, + new[] { "alsa_input.pci-0000_00_1f.3.analog-stereo:capture_FL" }).ToArray(); + + Assert.Equal(new[] { "Built-in Audio Analog Stereo:capture_FL" }, ports); + } + + [Fact] + public void WaveformWindowDiscoversNodeProbeCandidates() + { + Circuit.Circuit circuit = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")).Build(); + + ProbeCandidate[] candidates = WaveformWindow.ProbeCandidates(circuit).ToArray(); + + Assert.NotEmpty(candidates); + Assert.DoesNotContain(candidates, i => string.IsNullOrWhiteSpace(i.Name) || i.Name == "0"); + Assert.DoesNotContain(candidates, i => i.Expression.EqualsZero()); + } + + [Fact] + public void WaveformWindowGeneratesFallbackSineInput() + { + double[] input = new double[32]; + + WaveformWindow.FillInputBuffer(input, 48000, 440, 1, null); + + Assert.Contains(input, i => Math.Abs(i) > 1e-9); + } + + [Fact] + public void WaveformWindowGeneratesExpressionInput() + { + double[] input = new double[8]; + + WaveformWindow.FillInputBuffer(input, 48000, 440, 2, "0.125"); + + Assert.All(input, i => Assert.Equal(0.25, i, 12)); + } + + [Fact] + public void VirtualAudioStreamInvokesCallbackUntilStopped() + { + VirtualAudioDriver driver = new VirtualAudioDriver(); + Audio.Device device = driver.Devices.Single(); + using ManualResetEventSlim called = new ManualResetEventSlim(false); + int callbackCount = 0; + + Audio.Stream stream = device.Open((count, input, output, rate) => + { + callbackCount++; + Assert.Equal(256, count); + Assert.Equal(48000, rate); + Assert.Single(input); + Assert.Single(output); + output[0][0] = 0.25; + called.Set(); + }, device.InputChannels, device.OutputChannels); + + Assert.True(called.Wait(TimeSpan.FromSeconds(2))); + stream.Stop(); + Assert.True(callbackCount > 0); + } + + [Fact] + public async Task LiveAudioProcessorCanRunOffUiThread() + { + Schematic schematic = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + LiveAudioProcessor processor = new LiveAudioProcessor(schematic); + processor.Start(48000, 8, 8); + using Audio.SampleBuffer input = new Audio.SampleBuffer(128); + using Audio.SampleBuffer output = new Audio.SampleBuffer(128); + for (int i = 0; i < input.Samples.Length; i++) + input[i] = Math.Sin(2 * Math.PI * 440 * i / 48000); + + await Task.Run(() => processor.Process(128, new[] { input }, new[] { output }, 48000)); + + Assert.Contains(output.Samples, i => Math.Abs(i) > 1e-12); + } + + [Fact] + public void LinuxJackLiveModeProcessesBuiltInMicrophoneThroughRcFilter() + { + if (!string.Equals(Environment.GetEnvironmentVariable("LIVESPICE_RUN_JACK_HARDWARE_TEST"), "1", StringComparison.Ordinal)) + return; + + LinuxAudioDriver driver = new LinuxAudioDriver(); + Audio.Device? device = driver.Devices.SingleOrDefault(); + Assert.NotNull(device); + + Audio.Channel? microphone = BuiltIn(device!.InputChannels, ":capture_FL") ?? BuiltIn(device.InputChannels, ":capture_"); + Audio.Channel? playback = BuiltIn(device.OutputChannels, ":playback_FL") ?? BuiltIn(device.OutputChannels, ":playback_"); + Assert.NotNull(microphone); + Assert.NotNull(playback); + + Schematic schematic = Schematic.Load(FindFixture("Tests/Circuits/Passive 1stOrder Highpass RC.schx")); + LiveAudioProcessor liveProcessor = new LiveAudioProcessor(schematic); + List capturedInputs = new List(); + List capturedOutputs = new List(); + using ManualResetEventSlim completed = new ManualResetEventSlim(false); + int callbackCount = 0; + + Audio.Stream stream = device.Open((count, input, output, rate) => + { + if (callbackCount == 0) + liveProcessor.Start(rate, 4, 8); + + double[] inputCopy = input.Length == 0 ? new double[count] : input[0].Samples.Take(count).ToArray(); + liveProcessor.Process(count, input, output, rate); + double[] outputCopy = output.Length == 0 ? new double[count] : output[0].Samples.Take(count).ToArray(); + + lock (capturedInputs) + { + capturedInputs.Add(inputCopy); + capturedOutputs.Add(outputCopy); + callbackCount++; + if (callbackCount >= 8) + completed.Set(); + } + }, new[] { microphone! }, new[] { playback! }); + + try + { + Assert.True(completed.Wait(TimeSpan.FromSeconds(4)), "Timed out waiting for PipeWire/JACK live audio callbacks."); + } + finally + { + stream.Stop(); + liveProcessor.Stop(); + } + + double inputRms = Rms(capturedInputs.SelectMany(i => i)); + Assert.True(inputRms > 1e-5, $"Captured built-in microphone input is too close to silence. RMS={inputRms:R}."); + + LiveAudioProcessor referenceProcessor = new LiveAudioProcessor(schematic); + referenceProcessor.Start(stream.SampleRate, 4, 8); + List expectedSamples = new List(); + List actualSamples = new List(); + List inputSamples = new List(); + for (int block = 0; block < capturedInputs.Count; block++) + { + using Audio.SampleBuffer input = Buffer(capturedInputs[block]); + using Audio.SampleBuffer output = new Audio.SampleBuffer(capturedInputs[block].Length); + double[] expected = referenceProcessor.Process(capturedInputs[block].Length, new[] { input }, new[] { output }, stream.SampleRate); + + Assert.Equal(expected.Length, capturedOutputs[block].Length); + for (int sample = 0; sample < expected.Length; sample++) + Assert.Equal(expected[sample], capturedOutputs[block][sample], 12); + + expectedSamples.AddRange(expected); + actualSamples.AddRange(capturedOutputs[block]); + inputSamples.AddRange(capturedInputs[block]); + } + referenceProcessor.Stop(); + + double expectedRms = Rms(expectedSamples); + double actualRms = Rms(actualSamples); + double errorRms = Rms(expectedSamples.Zip(actualSamples, (expected, actual) => expected - actual)); + double copyErrorRms = Rms(inputSamples.Zip(actualSamples, (input, actual) => input - actual)); + + Assert.True(expectedRms > 1e-8, $"Expected RC output is too close to silence. RMS={expectedRms:R}."); + Assert.True(actualRms > 1e-8, $"Live RC output is too close to silence. RMS={actualRms:R}."); + Assert.True(errorRms <= Math.Max(1e-10, expectedRms * 1e-8), $"Live output does not match the RC prediction. Error RMS={errorRms:R}, expected RMS={expectedRms:R}."); + Assert.True(copyErrorRms > inputRms * 1e-3, $"Live output looks like an unfiltered copy of the input. Copy error RMS={copyErrorRms:R}, input RMS={inputRms:R}."); + } + + private static Audio.Channel? BuiltIn(IEnumerable channels, string suffix) + { + return channels.FirstOrDefault(i => i.Name.Contains("Built-in Audio", StringComparison.OrdinalIgnoreCase) && i.Name.Contains(suffix, StringComparison.OrdinalIgnoreCase)); + } + + private static Audio.SampleBuffer Buffer(double[] samples) + { + Audio.SampleBuffer buffer = new Audio.SampleBuffer(samples.Length); + Array.Copy(samples, buffer.Samples, samples.Length); + return buffer; + } + + private static double Rms(IEnumerable samples) + { + double sum = 0; + int count = 0; + foreach (double sample in samples) + { + sum += sample * sample; + count++; + } + return count == 0 ? 0 : Math.Sqrt(sum / count); + } + + 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); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/App.cs b/LiveSPICE.Avalonia/App.cs new file mode 100644 index 00000000..a4621f43 --- /dev/null +++ b/LiveSPICE.Avalonia/App.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Themes.Fluent; + +namespace LiveSPICE.Avalonia; + +public sealed class App : Application +{ + public override void Initialize() + { + Styles.Add(new FluentTheme()); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/AppSettings.cs b/LiveSPICE.Avalonia/AppSettings.cs new file mode 100644 index 00000000..d78c305d --- /dev/null +++ b/LiveSPICE.Avalonia/AppSettings.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace LiveSPICE.Avalonia; + +public sealed class AppSettings +{ + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions { WriteIndented = true }; + + public List RecentFiles { get; set; } = new List(); + + public double WindowWidth { get; set; } = 1200; + + public double WindowHeight { get; set; } = 800; + + public string AudioDriver { get; set; } = string.Empty; + + public string AudioDevice { get; set; } = string.Empty; + + public List AudioInputs { get; set; } = new List(); + + public List AudioOutputs { get; set; } = new List(); + + public static string SettingsPath + { + get + { + string root = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return Path.Combine(root, "LiveSPICE", "avalonia-settings.json"); + } + } + + public static AppSettings Load() + { + return Load(SettingsPath); + } + + internal static AppSettings Load(string path) + { + try + { + if (File.Exists(path)) + return JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new AppSettings(); + } + catch + { + } + + return new AppSettings(); + } + + public void Save() + { + Save(SettingsPath); + } + + internal void Save(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + File.WriteAllText(path, JsonSerializer.Serialize(this, Options)); + } + + public void MarkUsed(string path) + { + string fullPath = Path.GetFullPath(path); + RecentFiles.RemoveAll(i => string.Equals(Path.GetFullPath(i), fullPath, StringComparison.OrdinalIgnoreCase)); + RecentFiles.Insert(0, fullPath); + if (RecentFiles.Count > 20) + RecentFiles.RemoveRange(20, RecentFiles.Count - 20); + } + + public IEnumerable ExistingRecentFiles() + { + return RecentFiles.Where(File.Exists); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/AudioConfigWindow.cs b/LiveSPICE.Avalonia/AudioConfigWindow.cs new file mode 100644 index 00000000..a9c63c12 --- /dev/null +++ b/LiveSPICE.Avalonia/AudioConfigWindow.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Data; + +namespace LiveSPICE.Avalonia; + +public sealed class AudioConfigWindow : Window +{ + private readonly AppSettings settings; + private readonly ComboBox drivers = new ComboBox(); + private readonly ComboBox devices = new ComboBox(); + private readonly ListBox inputs = new ListBox { SelectionMode = SelectionMode.Multiple }; + private readonly ListBox outputs = new ListBox { SelectionMode = SelectionMode.Multiple }; + private readonly TextBlock status = new TextBlock(); + private Audio.Stream? testStream; + + public AudioConfigWindow(AppSettings settings) + { + this.settings = settings; + Title = "Audio Configuration"; + Width = 640; + Height = 500; + MinWidth = 520; + MinHeight = 360; + drivers.SelectionChanged += (_, _) => PopulateDevices(); + devices.SelectionChanged += (_, _) => PopulateChannels(); + drivers.DisplayMemberBinding = new Binding(nameof(Audio.Driver.Name)); + devices.DisplayMemberBinding = new Binding(nameof(Audio.Device.Name)); + Content = BuildContent(); + PopulateDrivers(); + Closed += (_, _) => StopTest(); + } + + private Control BuildContent() + { + DockPanel root = new DockPanel(); + + StackPanel buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(10) + }; + buttons.Children.Add(Button("Refresh", (_, _) => PopulateDrivers())); + buttons.Children.Add(Button("Test", (_, _) => ToggleTest())); + buttons.Children.Add(Button("Save", (_, _) => SaveAndClose())); + DockPanel.SetDock(buttons, Dock.Bottom); + root.Children.Add(buttons); + + StackPanel panel = new StackPanel { Spacing = 8, Margin = new global::Avalonia.Thickness(12) }; + panel.Children.Add(Label("Driver")); + panel.Children.Add(drivers); + panel.Children.Add(Label("Device / port graph")); + panel.Children.Add(devices); + + Grid channels = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), ColumnSpacing = 10 }; + channels.Children.Add(ChannelPanel("Inputs", inputs)); + Border outputPanel = ChannelPanel("Outputs", outputs); + Grid.SetColumn(outputPanel, 1); + channels.Children.Add(outputPanel); + panel.Children.Add(channels); + panel.Children.Add(status); + + root.Children.Add(panel); + return root; + } + + private void PopulateDrivers() + { + List availableDrivers = AvaloniaAudioDrivers.Available().ToList(); + drivers.ItemsSource = availableDrivers; + drivers.SelectedItem = availableDrivers.FirstOrDefault(i => i.Name == settings.AudioDriver) ?? availableDrivers.FirstOrDefault(); + PopulateDevices(); + status.Text = availableDrivers.Count == 0 ? "No audio drivers are available in this build." : DriverHint(drivers.SelectedItem as Audio.Driver); + } + + private void PopulateDevices() + { + Audio.Driver? driver = drivers.SelectedItem as Audio.Driver; + List availableDevices = driver?.Devices.ToList() ?? new List(); + devices.ItemsSource = availableDevices; + devices.SelectedItem = availableDevices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? availableDevices.FirstOrDefault(); + PopulateChannels(); + status.Text = driver == null ? "No audio driver selected." : DriverHint(driver); + } + + private void PopulateChannels() + { + Audio.Device? device = devices.SelectedItem as Audio.Device; + inputs.ItemsSource = device?.InputChannels ?? Array.Empty(); + outputs.ItemsSource = device?.OutputChannels ?? Array.Empty(); + SelectSaved(inputs, settings.AudioInputs); + SelectSaved(outputs, settings.AudioOutputs); + if (device != null) + status.Text = DeviceHint(device); + } + + private void SaveAndClose() + { + StopTest(); + settings.AudioDriver = (drivers.SelectedItem as Audio.Driver)?.Name ?? string.Empty; + settings.AudioDevice = (devices.SelectedItem as Audio.Device)?.Name ?? string.Empty; + settings.AudioInputs = inputs.SelectedItems?.OfType().Select(i => i.Name).ToList() ?? new List(); + settings.AudioOutputs = outputs.SelectedItems?.OfType().Select(i => i.Name).ToList() ?? new List(); + settings.Save(); + Close(); + } + + private void ToggleTest() + { + if (testStream != null) + { + StopTest(); + return; + } + + Audio.Device? device = devices.SelectedItem as Audio.Device; + if (device == null) + { + status.Text = "No audio device selected."; + return; + } + + try + { + testStream = device.Open(TestCallback, SelectedChannels(inputs), SelectedChannels(outputs)); + drivers.IsEnabled = false; + devices.IsEnabled = false; + inputs.IsEnabled = false; + outputs.IsEnabled = false; + status.Text = $"Testing at {testStream.SampleRate:0} Hz"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void StopTest() + { + if (testStream == null) + return; + + try + { + testStream.Stop(); + } + finally + { + testStream = null; + drivers.IsEnabled = true; + devices.IsEnabled = true; + inputs.IsEnabled = true; + outputs.IsEnabled = true; + status.Text = "Ready"; + } + } + + private static void TestCallback(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + for (int sample = 0; sample < count; sample++) + { + double value = 0; + foreach (Audio.SampleBuffer buffer in input) + value += buffer[sample]; + if (input.Length == 0) + value = 0.15 * Math.Sin(2 * Math.PI * 440 * sample / rate); + + foreach (Audio.SampleBuffer buffer in output) + buffer[sample] = value; + } + } + + private static Audio.Channel[] SelectedChannels(ListBox list) + { + return list.SelectedItems?.OfType().ToArray() ?? Array.Empty(); + } + + private static void SelectSaved(ListBox list, IEnumerable names) + { + if (list.SelectedItems == null) + return; + + list.SelectedItems.Clear(); + HashSet selected = names.ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (object? item in list.Items) + if (item is Audio.Channel channel && selected.Contains(channel.Name)) + list.SelectedItems.Add(item); + } + + private static string DriverHint(Audio.Driver? driver) + { + if (driver == null) + return "No audio driver selected."; + return driver.Name == "PipeWire/JACK" + ? "PipeWire/JACK exposes individual ports below; choose capture and playback ports there." + : "Ready"; + } + + private static string DeviceHint(Audio.Device device) + { + return device.Name == LinuxAudioDevice.DeviceName + ? "Select actual PipeWire/JACK input and output ports below." + : "Ready"; + } + + private static Border ChannelPanel(string title, ListBox list) + { + DockPanel dock = new DockPanel(); + TextBlock header = new TextBlock { Text = title, FontWeight = FontWeight.Bold, Margin = new global::Avalonia.Thickness(4) }; + DockPanel.SetDock(header, Dock.Top); + dock.Children.Add(header); + dock.Children.Add(list); + return new Border { BorderBrush = Brushes.LightGray, BorderThickness = new global::Avalonia.Thickness(1), MinHeight = 220, Child = dock }; + } + + private static TextBlock Label(string text) + { + return new TextBlock { Text = text, FontWeight = FontWeight.Bold }; + } + + private static Button Button(string text, EventHandler click) + { + Button button = new Button { Content = text, MinWidth = 82 }; + button.Click += click; + return button; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs b/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs new file mode 100644 index 00000000..b6ef3a06 --- /dev/null +++ b/LiveSPICE.Avalonia/AvaloniaAudioDrivers.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace LiveSPICE.Avalonia; + +internal static class AvaloniaAudioDrivers +{ + public static IReadOnlyList Available() + { + // Audio.Driver.Drivers discovers backends by reflecting over loaded assemblies, and + // .NET only loads an assembly when a type from it is first used - a ProjectReference + // alone is not enough. Touch the macOS backend so it is loaded before the scan. + // (A discarded typeof gets elided in Release builds; GC.KeepAlive is a real call.) + GC.KeepAlive(typeof(global::CoreAudio.Driver)); + + List drivers = new List(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + AddIfUsable(drivers, () => new LinuxAudioDriver()); + + AddIfUsable(drivers, () => new VirtualAudioDriver()); + + foreach (Audio.Driver driver in Audio.Driver.Drivers) + if (!drivers.Any(i => string.Equals(i.Name, driver.Name, StringComparison.OrdinalIgnoreCase))) + drivers.Add(driver); + + return drivers; + } + + private static void AddIfUsable(List drivers, Func factory) + { + try + { + Audio.Driver driver = factory(); + if (!drivers.Any(i => string.Equals(i.Name, driver.Name, StringComparison.OrdinalIgnoreCase))) + drivers.Add(driver); + } + catch + { + } + } +} diff --git a/LiveSPICE.Avalonia/ConfirmWindow.cs b/LiveSPICE.Avalonia/ConfirmWindow.cs new file mode 100644 index 00000000..89755b35 --- /dev/null +++ b/LiveSPICE.Avalonia/ConfirmWindow.cs @@ -0,0 +1,61 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace LiveSPICE.Avalonia; + +public sealed class ConfirmWindow : Window +{ + private bool result; + + private ConfirmWindow(string title, string message) + { + Title = title; + Width = 460; + Height = 180; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + + TextBlock text = new TextBlock + { + Text = message, + TextWrapping = TextWrapping.Wrap, + Margin = new global::Avalonia.Thickness(16) + }; + + StackPanel buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(16) + }; + buttons.Children.Add(Button("Yes", true)); + buttons.Children.Add(Button("No", false)); + + DockPanel dock = new DockPanel(); + DockPanel.SetDock(buttons, Dock.Bottom); + dock.Children.Add(buttons); + dock.Children.Add(text); + Content = dock; + } + + public static async Task ShowAsync(Window owner, string title, string message) + { + ConfirmWindow window = new ConfirmWindow(title, message); + await window.ShowDialog(owner); + return window.result; + } + + private Button Button(string text, bool value) + { + Button button = new Button { Content = text, MinWidth = 80 }; + button.Click += (_, _) => + { + result = value; + Close(); + }; + return button; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LinuxAudioDriver.cs b/LiveSPICE.Avalonia/LinuxAudioDriver.cs new file mode 100644 index 00000000..8d066fcd --- /dev/null +++ b/LiveSPICE.Avalonia/LinuxAudioDriver.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; + +namespace LiveSPICE.Avalonia; + +public sealed class LinuxAudioDriver : Audio.Driver +{ + public LinuxAudioDriver() + { + LinuxAudioChannel[] inputs = LinuxAudioDiscovery.InputPorts().Select(i => new LinuxAudioChannel(i)).ToArray(); + LinuxAudioChannel[] outputs = LinuxAudioDiscovery.OutputPorts().Select(i => new LinuxAudioChannel(i)).ToArray(); + if (inputs.Length > 0 || outputs.Length > 0) + devices.Add(new LinuxAudioDevice(inputs, outputs)); + } + + public override string Name => "PipeWire/JACK"; + + public override string ToString() + { + return Name; + } +} + +internal sealed class LinuxAudioChannel : Audio.Channel +{ + public LinuxAudioChannel(string name) + { + Name = name; + } + + public override string Name { get; } + + public override string ToString() + { + return Name; + } +} + +internal sealed class LinuxAudioDevice : Audio.Device +{ + public const string DeviceName = "PipeWire/JACK port graph"; + + public LinuxAudioDevice(Audio.Channel[] input, Audio.Channel[] output) : base(DeviceName) + { + inputs = input; + outputs = output; + } + + public override string ToString() + { + return Name; + } + + public override Audio.Stream Open(Audio.Stream.SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) + { + return new JackAudioStream(callback, input.Cast().ToArray(), output.Cast().ToArray()); + } +} + +internal sealed unsafe class JackAudioStream : Audio.Stream +{ + private const uint JackNullOption = 0; + private const ulong JackPortIsInput = 1; + private const ulong JackPortIsOutput = 2; + private const string DefaultAudioType = "32 bit float mono audio"; + + private readonly SampleHandler callback; + private readonly IntPtr client; + private readonly IntPtr[] inputPorts; + private readonly IntPtr[] outputPorts; + private readonly Audio.SampleBuffer[] inputBuffers; + private readonly Audio.SampleBuffer[] outputBuffers; + private readonly JackProcessCallback processCallback; + private readonly double sampleRate; + private bool stopped; + + public JackAudioStream(SampleHandler callback, LinuxAudioChannel[] input, LinuxAudioChannel[] output) : base(input, output) + { + this.callback = callback; + int status; + client = JackNative.jack_client_open("LiveSPICE", JackNullOption, out status); + if (client == IntPtr.Zero) + throw new InvalidOperationException("Could not open JACK client. Is JACK or PipeWire JACK running?"); + + sampleRate = JackNative.jack_get_sample_rate(client); + inputPorts = new IntPtr[input.Length]; + outputPorts = new IntPtr[output.Length]; + inputBuffers = input.Select(_ => new Audio.SampleBuffer(1)).ToArray(); + outputBuffers = output.Select(_ => new Audio.SampleBuffer(1)).ToArray(); + + for (int i = 0; i < inputPorts.Length; i++) + inputPorts[i] = RegisterPort($"input_{i + 1}", JackPortIsInput); + for (int i = 0; i < outputPorts.Length; i++) + outputPorts[i] = RegisterPort($"output_{i + 1}", JackPortIsOutput); + + processCallback = Process; + JackNative.Check(JackNative.jack_set_process_callback(client, processCallback, IntPtr.Zero), "set JACK process callback"); + JackNative.Check(JackNative.jack_activate(client), "activate JACK client"); + + for (int i = 0; i < input.Length; i++) + { + string sourcePort = input[i].Name; + string destinationPort = JackNative.PortName(inputPorts[i]); + JackNative.Check(JackNative.jack_connect(client, sourcePort, destinationPort), $"connect JACK port {sourcePort} to {destinationPort}"); + } + for (int i = 0; i < output.Length; i++) + { + string sourcePort = JackNative.PortName(outputPorts[i]); + string destinationPort = output[i].Name; + JackNative.Check(JackNative.jack_connect(client, sourcePort, destinationPort), $"connect JACK port {sourcePort} to {destinationPort}"); + } + } + + public override double SampleRate => sampleRate; + + public override void Stop() + { + if (stopped) + return; + + stopped = true; + JackNative.jack_deactivate(client); + JackNative.jack_client_close(client); + foreach (Audio.SampleBuffer buffer in inputBuffers.Concat(outputBuffers)) + buffer.Dispose(); + } + + private IntPtr RegisterPort(string name, ulong flags) + { + IntPtr port = JackNative.jack_port_register(client, name, DefaultAudioType, flags, 0); + if (port == IntPtr.Zero) + throw new InvalidOperationException($"Could not register JACK port '{name}'."); + return port; + } + + private int Process(uint frameCount, IntPtr arg) + { + if (stopped) + return 0; + + try + { + EnsureBuffers((int)frameCount); + for (int channel = 0; channel < inputPorts.Length; channel++) + { + float* source = (float*)JackNative.jack_port_get_buffer(inputPorts[channel], frameCount); + for (int sample = 0; sample < frameCount; sample++) + inputBuffers[channel][sample] = source[sample]; + } + + callback((int)frameCount, inputBuffers, outputBuffers, sampleRate); + + for (int channel = 0; channel < outputPorts.Length; channel++) + { + float* destination = (float*)JackNative.jack_port_get_buffer(outputPorts[channel], frameCount); + for (int sample = 0; sample < frameCount; sample++) + destination[sample] = (float)Math.Clamp(outputBuffers[channel][sample], -1, 1); + } + } + catch + { + for (int channel = 0; channel < outputPorts.Length; channel++) + { + float* destination = (float*)JackNative.jack_port_get_buffer(outputPorts[channel], frameCount); + for (int sample = 0; sample < frameCount; sample++) + destination[sample] = 0; + } + } + return 0; + } + + private void EnsureBuffers(int frameCount) + { + for (int i = 0; i < inputBuffers.Length; i++) + if (inputBuffers[i].Samples.Length != frameCount) + { + inputBuffers[i].Dispose(); + inputBuffers[i] = new Audio.SampleBuffer(frameCount); + } + for (int i = 0; i < outputBuffers.Length; i++) + if (outputBuffers[i].Samples.Length != frameCount) + { + outputBuffers[i].Dispose(); + outputBuffers[i] = new Audio.SampleBuffer(frameCount); + } + } +} + +internal delegate int JackProcessCallback(uint frames, IntPtr arg); + +internal static class JackNative +{ + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr jack_client_open(string clientName, uint options, out int status); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_client_close(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr jack_port_register(IntPtr client, string portName, string portType, ulong flags, ulong bufferSize); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_set_process_callback(IntPtr client, JackProcessCallback callback, IntPtr arg); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_activate(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_deactivate(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern uint jack_get_sample_rate(IntPtr client); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr jack_port_get_buffer(IntPtr port, uint frames); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr jack_port_name(IntPtr port); + + [DllImport("jack", CallingConvention = CallingConvention.Cdecl)] + public static extern int jack_connect(IntPtr client, string sourcePort, string destinationPort); + + public static string PortName(IntPtr port) + { + return Marshal.PtrToStringAnsi(jack_port_name(port)) ?? string.Empty; + } + + public static void Check(int result, string operation) + { + if (result != 0) + throw new InvalidOperationException($"Could not {operation}. JACK error code {result}."); + } +} + +internal static class LinuxAudioDiscovery +{ + public static IEnumerable InputPorts() + { + return PreferredPorts(JackPorts(":capture_", ":monitor_"), AudioPorts("pw-link", "-o")); + } + + public static IEnumerable OutputPorts() + { + return PreferredPorts(JackPorts(":playback_"), AudioPorts("pw-link", "-i")); + } + + internal static IEnumerable PreferredPorts(IEnumerable jackPorts, IEnumerable pipeWirePorts) + { + string[] preferred = jackPorts.Where(IsAudioPort).ToArray(); + return (preferred.Length > 0 ? preferred : pipeWirePorts) + .Where(IsAudioPort) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i, StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable JackPorts(params string[] portKinds) + { + return AudioPorts("jack_lsp", null) + .Where(port => portKinds.Any(kind => port.Contains(kind, StringComparison.OrdinalIgnoreCase))); + } + + private static IEnumerable AudioPorts(string command, string? arguments) + { + try + { + ProcessStartInfo startInfo = new ProcessStartInfo(command, arguments ?? string.Empty) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using Process process = Process.Start(startInfo)!; + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(1000)) + { + process.Kill(true); + process.WaitForExit(); + return Array.Empty(); + } + _ = errorTask.GetAwaiter().GetResult(); + if (process.ExitCode != 0) + return Array.Empty(); + + string output = outputTask.GetAwaiter().GetResult(); + + return output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(i => i.Trim()) + .Where(i => i.Length > 0) + .ToArray(); + } + catch + { + return Array.Empty(); + } + } + + private static bool IsAudioPort(string port) + { + return !port.StartsWith("Midi-Bridge:", StringComparison.OrdinalIgnoreCase) + && !port.StartsWith("v4l2_input.", StringComparison.OrdinalIgnoreCase) + && !port.StartsWith("libcamera_input.", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LiveAudioProcessor.cs b/LiveSPICE.Avalonia/LiveAudioProcessor.cs new file mode 100644 index 00000000..ccd3f2dc --- /dev/null +++ b/LiveSPICE.Avalonia/LiveAudioProcessor.cs @@ -0,0 +1,68 @@ +using System; +using Circuit; + +namespace LiveSPICE.Avalonia; + +internal sealed class LiveAudioProcessor +{ + private readonly object sync = new object(); + private readonly Schematic schematic; + private Simulation? simulation; + + public LiveAudioProcessor(Schematic schematic) + { + this.schematic = schematic; + } + + public double InputScale { get; set; } = 1; + + public double OutputScale { get; set; } = 1; + + public void Start(double sampleRate, int oversample, int iterations) + { + lock (sync) + { + simulation = AudioSimulationFactory.Create(schematic.Build(), sampleRate, oversample, iterations); + } + } + + public void Stop() + { + lock (sync) + { + simulation = null; + } + } + + public double[] Process(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + Simulation? current; + lock (sync) + current = simulation; + + if (current == null) + { + foreach (Audio.SampleBuffer buffer in output) + buffer.Clear(); + return Array.Empty(); + } + + double[] inputSamples = new double[count]; + 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)); + for (int sample = 0; sample < count; sample++) + inputSamples[sample] *= InputScale; + + double[] outputSamples = new double[count]; + lock (sync) + current.Run(count, new[] { inputSamples }, new[] { outputSamples }); + for (int sample = 0; sample < count; sample++) + outputSamples[sample] *= OutputScale; + foreach (Audio.SampleBuffer buffer in output) + Array.Copy(outputSamples, buffer.Samples, count); + return outputSamples; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj new file mode 100644 index 00000000..2777c32a --- /dev/null +++ b/LiveSPICE.Avalonia/LiveSPICE.Avalonia.csproj @@ -0,0 +1,29 @@ + + + WinExe + net8.0 + enable + enable + true + LiveSPICE.Avalonia + LiveSPICE.Avalonia + + + + <_Parameter1>LiveSPICE.Avalonia.Tests + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LiveSPICE.Avalonia/MainWindow.cs b/LiveSPICE.Avalonia/MainWindow.cs new file mode 100644 index 00000000..16bb3218 --- /dev/null +++ b/LiveSPICE.Avalonia/MainWindow.cs @@ -0,0 +1,833 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Circuit; +using Util; +using CircuitComponent = Circuit.Component; + +namespace LiveSPICE.Avalonia; + +public sealed class MainWindow : Window +{ + private static readonly Type[] CommonComponents = + { + typeof(Conductor), + typeof(Ground), + typeof(Rail), + typeof(Resistor), + typeof(Capacitor), + typeof(Inductor), + typeof(VoltageSource), + typeof(CurrentSource), + typeof(NamedWire), + typeof(Circuit.Label) + }; + + private readonly TabControl documents = new TabControl(); + private readonly ListBox componentList = new ListBox(); + private readonly PropertyInspector propertyInspector = new PropertyInspector(); + private readonly TextBlock status = new TextBlock { Text = "Ready", VerticalAlignment = VerticalAlignment.Center }; + private readonly AppSettings settings = AppSettings.Load(); + private readonly MenuItem recentFilesMenu = new MenuItem { Header = "Recent Files" }; + private bool suppressSelectionEvent; + private bool closeConfirmed; + + public MainWindow() + { + Title = "LiveSPICE Avalonia"; + Width = Math.Max(settings.WindowWidth, 800); + Height = Math.Max(settings.WindowHeight, 500); + MinWidth = 800; + MinHeight = 500; + KeyDown += OnKeyDown; + Activated += async (_, _) => await CheckExternalModificationsAsync(); + + DockPanel root = new DockPanel(); + + Menu menu = BuildMenu(); + DockPanel.SetDock(menu, Dock.Top); + root.Children.Add(menu); + + Control toolbar = BuildToolbar(); + DockPanel.SetDock(toolbar, Dock.Top); + root.Children.Add(toolbar); + + Border statusBar = new Border + { + Background = Brushes.WhiteSmoke, + BorderBrush = Brushes.LightGray, + BorderThickness = new global::Avalonia.Thickness(1, 0, 0, 0), + Padding = new global::Avalonia.Thickness(8, 4), + Child = status + }; + DockPanel.SetDock(statusBar, Dock.Bottom); + root.Children.Add(statusBar); + + Grid content = new Grid + { + ColumnDefinitions = new ColumnDefinitions("240,* ,300") + }; + + componentList.Margin = new global::Avalonia.Thickness(6); + componentList.DoubleTapped += (_, _) => ActivateSelectedComponent(); + componentList.SelectionChanged += (_, _) => ActivateSelectedComponent(); + PopulateComponents(); + + Border componentsPanel = Panel("Components", componentList); + Grid.SetColumn(componentsPanel, 0); + content.Children.Add(componentsPanel); + + documents.SelectionChanged += (_, _) => OnActiveDocumentChanged(); + Grid.SetColumn(documents, 1); + content.Children.Add(documents); + + propertyInspector.PropertyChangedByUser += action => + { + ActiveDocument?.Do(action); + ActiveCanvas?.InvalidateVisual(); + RefreshDocumentHeaders(); + }; + Border propertiesPanel = Panel("Properties", propertyInspector); + Grid.SetColumn(propertiesPanel, 2); + content.Children.Add(propertiesPanel); + + root.Children.Add(content); + Content = root; + } + + private SchematicDocument? ActiveDocument => ActiveTab?.Tag as SchematicDocument; + + private SchematicCanvas? ActiveCanvas => ActiveTab?.Content as SchematicCanvas; + + private TabItem? ActiveTab => documents.SelectedItem as TabItem; + + private Menu BuildMenu() + { + Menu menu = new Menu + { + ItemsSource = new[] + { + new MenuItem + { + Header = "_File", + ItemsSource = new Control[] + { + MenuItem("_New", (_, _) => NewDocument()), + MenuItem("_Open", async (_, _) => await OpenSchematicAsync()), + MenuItem("_Save", async (_, _) => await SaveActiveAsync()), + MenuItem("Save _As", async (_, _) => await SaveActiveAsAsync()), + MenuItem("Save A_ll", async (_, _) => await SaveAllAsync()), + new Separator(), + recentFilesMenu, + new Separator(), + MenuItem("_Close", async (_, _) => await CloseActiveAsync()), + MenuItem("E_xit", (_, _) => Close()) + } + }, + new MenuItem + { + Header = "_Edit", + ItemsSource = new Control[] + { + MenuItem("_Delete", (_, _) => ActiveCanvas?.DeleteSelection()), + MenuItem("_Undo", (_, _) => UndoActive()), + MenuItem("_Redo", (_, _) => RedoActive()), + new Separator(), + MenuItem("Cu_t", async (_, _) => await CutSelectionAsync()), + MenuItem("_Copy", async (_, _) => await CopySelectionAsync()), + MenuItem("_Paste", async (_, _) => await PasteSelectionAsync()), + MenuItem("Select _All", (_, _) => ActiveCanvas?.SelectAll()), + new Separator(), + MenuItem("Rotate Left", (_, _) => ActiveCanvas?.RotateSelection(1)), + MenuItem("Rotate Right", (_, _) => ActiveCanvas?.RotateSelection(-1)), + MenuItem("Flip", (_, _) => ActiveCanvas?.FlipSelection()) + } + }, + new MenuItem + { + Header = "_View", + ItemsSource = new Control[] + { + MenuItem("Zoom _In", (_, _) => ZoomActive(1.2)), + MenuItem("Zoom _Out", (_, _) => ZoomActive(1 / 1.2)), + MenuItem("Zoom _Fit", (_, _) => ActiveCanvas?.FitToView()) + } + }, + new MenuItem + { + Header = "_Simulate", + ItemsSource = new Control[] + { + MenuItem("Audio Settings", (_, _) => new AudioConfigWindow(settings).Show()), + MenuItem("Validate Build", (_, _) => ValidateActiveCircuit()), + MenuItem("Run Simulation", (_, _) => RunSimulation()) + } + }, + new MenuItem + { + Header = "_About", + ItemsSource = new Control[] + { + MenuItem("About", (_, _) => status.Text = "LiveSPICE Avalonia GUI port") + } + } + } + }; + RefreshRecentFilesMenu(); + return menu; + } + + private static MenuItem MenuItem(string header, EventHandler click) + { + MenuItem item = new MenuItem { Header = header }; + item.Click += click; + return item; + } + + private Control BuildToolbar() + { + StackPanel toolbar = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new global::Avalonia.Thickness(8), + Children = + { + Button("New", (_, _) => NewDocument()), + Button("Open", async (_, _) => await OpenSchematicAsync()), + Button("Save", async (_, _) => await SaveActiveAsync()), + Button("Undo", (_, _) => UndoActive()), + Button("Redo", (_, _) => RedoActive()), + Button("Copy", async (_, _) => await CopySelectionAsync()), + Button("Paste", async (_, _) => await PasteSelectionAsync()), + Button("Delete", (_, _) => ActiveCanvas?.DeleteSelection()), + Button("Wire", (_, _) => BeginWireTool()), + Button("Run", (_, _) => RunSimulation()), + Button("+", (_, _) => ZoomActive(1.2), 36), + Button("-", (_, _) => ZoomActive(1 / 1.2), 36), + Button("Fit", (_, _) => ActiveCanvas?.FitToView()) + } + }; + return toolbar; + } + + private static Button Button(string text, EventHandler click, double minWidth = 64) + { + Button button = new Button { Content = text, MinWidth = minWidth }; + button.Click += click; + return button; + } + + private static Border Panel(string title, Control content) + { + DockPanel dock = new DockPanel(); + TextBlock header = new TextBlock + { + Text = title, + FontWeight = FontWeight.Bold, + Margin = new global::Avalonia.Thickness(8, 8, 8, 4) + }; + DockPanel.SetDock(header, Dock.Top); + dock.Children.Add(header); + dock.Children.Add(content); + + return new Border + { + BorderBrush = Brushes.LightGray, + BorderThickness = new global::Avalonia.Thickness(1), + Child = dock + }; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + string[] paths = Environment.GetCommandLineArgs() + .Skip(1) + .Where(i => i.EndsWith(".schx", StringComparison.OrdinalIgnoreCase) && File.Exists(i)) + .ToArray(); + + if (paths.Length > 0) + foreach (string path in paths) + LoadSchematic(path); + else + NewDocument(); + + string? screenshotPath = Environment.GetEnvironmentVariable("LIVESPICE_SCREENSHOT"); + if (!string.IsNullOrWhiteSpace(screenshotPath)) + Dispatcher.UIThread.Post(() => SaveScreenshotAndExit(screenshotPath), DispatcherPriority.ApplicationIdle); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + if (!closeConfirmed && documents.Items.OfType().Any(i => (i.Tag as SchematicDocument)?.Dirty == true)) + { + e.Cancel = true; + Dispatcher.UIThread.Post(async () => + { + if (await CloseAllDocumentsAsync()) + { + closeConfirmed = true; + Close(); + } + }); + return; + } + + settings.WindowWidth = Width; + settings.WindowHeight = Height; + settings.Save(); + base.OnClosing(e); + } + + private async System.Threading.Tasks.Task OpenSchematicAsync() + { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open LiveSPICE schematic", + AllowMultiple = true, + FileTypeFilter = new[] + { + new FilePickerFileType("Circuit Schematics") { Patterns = new[] { "*.schx" } }, + FilePickerFileTypes.All + } + }); + + foreach (string path in files.Select(OpenPath).Where(i => i != null).Cast()) + LoadSchematic(path); + } + + internal static string? OpenPath(IStorageFile file) + { + return OpenPath(file.TryGetLocalPath(), file.Path); + } + + internal static string? OpenPath(string? localPath, Uri uri) + { + if (!string.IsNullOrWhiteSpace(localPath)) + return localPath; + + if (uri.IsFile) + return uri.LocalPath; + + return null; + } + + private void LoadSchematic(string path) + { + try + { + string fullPath = Path.GetFullPath(path); + TabItem? existing = documents.Items.OfType() + .FirstOrDefault(i => string.Equals(Path.GetFullPath(((SchematicDocument)i.Tag!).FilePath ?? string.Empty), fullPath, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + documents.SelectedItem = existing; + status.Text = $"Selected {Path.GetFileName(path)}"; + return; + } + + ReplaceUntouchedStartupDocument(); + AddDocument(SchematicDocument.Open(path)); + settings.MarkUsed(path); + RefreshRecentFilesMenu(); + status.Text = $"Loaded {Path.GetFileName(path)}"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void ReplaceUntouchedStartupDocument() + { + if (documents.Items.Count != 1) + return; + + if (documents.Items[0] is TabItem { Tag: SchematicDocument document } tab && IsUntouchedStartupDocument(document)) + documents.Items.Remove(tab); + } + + internal static bool IsUntouchedStartupDocument(SchematicDocument document) + { + return document.FilePath == null && !document.Dirty && !document.Schematic.Elements.Any(); + } + + private void NewDocument() + { + AddDocument(SchematicDocument.New()); + status.Text = "Created new schematic"; + } + + private void AddDocument(SchematicDocument document) + { + SchematicCanvas canvas = new SchematicCanvas { Document = document }; + canvas.SelectionChanged += OnCanvasSelectionChanged; + canvas.DocumentChanged += () => + { + RefreshDocumentHeaders(); + status.Text = document.Title; + }; + canvas.ContextMenu = BuildCanvasContextMenu(); + + TabItem tab = new TabItem + { + Header = document.Title, + Content = canvas, + Tag = document + }; + documents.Items.Add(tab); + documents.SelectedItem = tab; + canvas.FitToView(); + } + + private ContextMenu BuildCanvasContextMenu() + { + return new ContextMenu + { + ItemsSource = new Control[] + { + MenuItem("Undo", (_, _) => UndoActive()), + MenuItem("Redo", (_, _) => RedoActive()), + new Separator(), + MenuItem("Cut", async (_, _) => await CutSelectionAsync()), + MenuItem("Copy", async (_, _) => await CopySelectionAsync()), + MenuItem("Paste", async (_, _) => await PasteSelectionAsync()), + MenuItem("Delete", (_, _) => ActiveCanvas?.DeleteSelection()), + new Separator(), + MenuItem("Rotate Left", (_, _) => ActiveCanvas?.RotateSelection(1)), + MenuItem("Rotate Right", (_, _) => ActiveCanvas?.RotateSelection(-1)), + MenuItem("Flip", (_, _) => ActiveCanvas?.FlipSelection()), + MenuItem("Select All", (_, _) => ActiveCanvas?.SelectAll()) + } + }; + } + + private void RefreshDocumentHeaders() + { + foreach (TabItem item in documents.Items.OfType()) + if (item.Tag is SchematicDocument document) + item.Header = document.Title; + } + + private void OnActiveDocumentChanged() + { + if (suppressSelectionEvent) + return; + + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + if (ActiveDocument != null) + Title = $"LiveSPICE Avalonia - {ActiveDocument.Title}"; + } + + private void OnCanvasSelectionChanged() + { + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + } + + private void ZoomActive(double multiplier) + { + if (ActiveCanvas != null) + ActiveCanvas.Zoom *= multiplier; + } + + private async System.Threading.Tasks.Task CloseActiveAsync() + { + if (ActiveTab != null && await TryCloseTabAsync(ActiveTab)) + { + documents.Items.Remove(ActiveTab); + if (documents.Items.Count == 0) + NewDocument(); + } + } + + private async System.Threading.Tasks.Task CloseAllDocumentsAsync() + { + foreach (TabItem tab in documents.Items.OfType().ToList()) + { + documents.SelectedItem = tab; + if (!await TryCloseTabAsync(tab)) + return false; + documents.Items.Remove(tab); + } + + return true; + } + + private async System.Threading.Tasks.Task TryCloseTabAsync(TabItem tab) + { + if (tab.Tag is not SchematicDocument document || !document.Dirty) + return true; + + SaveChoice choice = await SavePromptWindow.ShowAsync(this, document.Title); + if (choice == SaveChoice.Cancel) + return false; + if (choice == SaveChoice.Discard) + return true; + + documents.SelectedItem = tab; + await SaveActiveAsync(); + return !document.Dirty; + } + + private async System.Threading.Tasks.Task CheckExternalModificationsAsync() + { + List modified = documents.Items.OfType() + .Where(i => (i.Tag as SchematicDocument)?.WasModifiedExternally() == true) + .ToList(); + if (modified.Count == 0) + return; + + string names = string.Join("\n", modified.Select(i => ((SchematicDocument)i.Tag!).FilePath)); + if (!await ConfirmWindow.ShowAsync(this, "Reload Modified Schematics", "Reload schematics modified outside LiveSPICE?\n\n" + names)) + return; + + foreach (TabItem tab in modified) + { + if (tab.Tag is not SchematicDocument document || document.FilePath == null) + continue; + + int index = documents.Items.IndexOf(tab); + documents.Items.Remove(tab); + SchematicDocument reloaded = SchematicDocument.Open(document.FilePath); + SchematicCanvas canvas = new SchematicCanvas { Document = reloaded }; + canvas.SelectionChanged += OnCanvasSelectionChanged; + canvas.DocumentChanged += () => + { + RefreshDocumentHeaders(); + status.Text = reloaded.Title; + }; + canvas.ContextMenu = BuildCanvasContextMenu(); + TabItem replacement = new TabItem { Header = reloaded.Title, Content = canvas, Tag = reloaded }; + documents.Items.Insert(index, replacement); + documents.SelectedItem = replacement; + canvas.FitToView(); + } + + RefreshDocumentHeaders(); + status.Text = "Reloaded externally modified schematics"; + } + + private async System.Threading.Tasks.Task SaveActiveAsync() + { + if (ActiveDocument == null) + return; + + if (ActiveDocument.FilePath == null) + await SaveActiveAsAsync(); + else + SaveDocument(ActiveDocument, ActiveDocument.FilePath); + } + + private async System.Threading.Tasks.Task SaveActiveAsAsync() + { + if (ActiveDocument == null) + return; + + IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save LiveSPICE schematic", + SuggestedFileName = ActiveDocument.FilePath == null ? "Untitled.schx" : Path.GetFileName(ActiveDocument.FilePath), + FileTypeChoices = new[] { new FilePickerFileType("Circuit Schematics") { Patterns = new[] { "*.schx" } } }, + DefaultExtension = "schx" + }); + + string? path = file?.TryGetLocalPath(); + if (path != null) + SaveDocument(ActiveDocument, path); + } + + private async System.Threading.Tasks.Task SaveAllAsync() + { + foreach (TabItem tab in documents.Items.OfType().ToList()) + { + suppressSelectionEvent = true; + documents.SelectedItem = tab; + suppressSelectionEvent = false; + await SaveActiveAsync(); + } + OnActiveDocumentChanged(); + } + + private void SaveDocument(SchematicDocument document, string path) + { + try + { + document.Save(path); + settings.MarkUsed(path); + settings.Save(); + RefreshRecentFilesMenu(); + RefreshDocumentHeaders(); + status.Text = $"Saved {Path.GetFileName(path)}"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void RefreshRecentFilesMenu() + { + List items = settings.ExistingRecentFiles() + .Select(path => MenuItem(CompactPath(path, 48), (_, _) => LoadSchematic(path))) + .Cast() + .ToList(); + + if (items.Count == 0) + items.Add(new MenuItem { Header = "(empty)", IsEnabled = false }); + + recentFilesMenu.ItemsSource = items; + } + + private static string CompactPath(string path, int maxLength) + { + if (path.Length <= maxLength) + return path; + + string file = Path.GetFileName(path); + string directory = Path.GetDirectoryName(path) ?? string.Empty; + int keep = Math.Max(0, maxLength - file.Length - 5); + if (directory.Length > keep) + directory = "..." + directory[^keep..]; + return Path.Combine(directory, file); + } + + private void PopulateComponents() + { + List items = CommonComponents.Select(i => new ComponentListItem(i)).ToList(); + Type root = typeof(CircuitComponent); + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies().Where(i => !i.IsDynamic)) + { + try + { + foreach (Type type in assembly.GetTypes().Where(i => i.IsPublic && !i.IsAbstract && root.IsAssignableFrom(i) && i.CustomAttribute() == null)) + if (items.All(i => i.ComponentType != type)) + items.Add(new ComponentListItem(type)); + } + catch + { + } + } + + componentList.ItemsSource = items.OrderBy(i => i.Name).ToList(); + } + + private void ActivateSelectedComponent() + { + if (componentList.SelectedItem is not ComponentListItem item || ActiveCanvas == null) + return; + + if (item.ComponentType == typeof(Conductor)) + { + ActiveCanvas.BeginWireTool(); + status.Text = "Wire tool: click two grid points"; + return; + } + + ActiveCanvas.PendingComponent = (CircuitComponent)Activator.CreateInstance(item.ComponentType)!; + status.Text = $"Place {item.Name}: click schematic"; + } + + private void BeginWireTool() + { + componentList.SelectedItem = null; + ActiveCanvas?.BeginWireTool(); + status.Text = "Wire tool: click two grid points"; + } + + private void ValidateActiveCircuit() + { + try + { + ActiveDocument?.Schematic.Build(); + status.Text = "Circuit build succeeded"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void SaveScreenshotAndExit(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + RenderTargetBitmap bitmap = new RenderTargetBitmap(new PixelSize((int)Bounds.Width, (int)Bounds.Height), new Vector(96, 96)); + bitmap.Render(this); + bitmap.Save(path); + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.Shutdown(); + } + + private void UndoActive() + { + ActiveDocument?.Undo(); + ActiveCanvas?.InvalidateVisual(); + RefreshDocumentHeaders(); + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + status.Text = "Undo"; + } + + private void RedoActive() + { + ActiveDocument?.Redo(); + ActiveCanvas?.InvalidateVisual(); + RefreshDocumentHeaders(); + propertyInspector.SetSelectedObjects(ActiveCanvas?.SelectedObjects ?? Array.Empty()); + status.Text = "Redo"; + } + + private void RunSimulation() + { + try + { + if (ActiveDocument == null) + return; + + new WaveformWindow(ActiveDocument.Schematic, settings).Show(); + status.Text = "Simulation window opened"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private async System.Threading.Tasks.Task CopySelectionAsync() + { + string? xml = ActiveCanvas?.CopySelectionXml(); + if (!string.IsNullOrWhiteSpace(xml) && Clipboard != null) + { + await Clipboard.SetTextAsync(xml); + status.Text = "Copied selection"; + } + } + + private async System.Threading.Tasks.Task CutSelectionAsync() + { + await CopySelectionAsync(); + ActiveCanvas?.DeleteSelection(); + status.Text = "Cut selection"; + } + + private async System.Threading.Tasks.Task PasteSelectionAsync() + { + if (ActiveCanvas == null || Clipboard == null) + return; + + string? xml = await Clipboard.GetTextAsync(); + if (!string.IsNullOrWhiteSpace(xml) && ActiveCanvas.PasteSelectionXml(xml)) + status.Text = "Pasted selection"; + } + + private async void OnKeyDown(object? sender, KeyEventArgs e) + { + KeyModifiers modifiers = e.KeyModifiers; + if (modifiers.HasFlag(KeyModifiers.Control)) + { + switch (e.Key) + { + case Key.N: + NewDocument(); + e.Handled = true; + return; + case Key.O: + await OpenSchematicAsync(); + e.Handled = true; + return; + case Key.S when modifiers.HasFlag(KeyModifiers.Shift): + await SaveAllAsync(); + e.Handled = true; + return; + case Key.S: + await SaveActiveAsync(); + e.Handled = true; + return; + case Key.C: + await CopySelectionAsync(); + e.Handled = true; + return; + case Key.X: + await CutSelectionAsync(); + e.Handled = true; + return; + case Key.Z: + UndoActive(); + e.Handled = true; + return; + case Key.Y: + RedoActive(); + e.Handled = true; + return; + case Key.V: + await PasteSelectionAsync(); + e.Handled = true; + return; + case Key.A: + ActiveCanvas?.SelectAll(); + e.Handled = true; + return; + } + } + + switch (e.Key) + { + case Key.Delete: + ActiveCanvas?.DeleteSelection(); + e.Handled = true; + break; + case Key.F5: + RunSimulation(); + e.Handled = true; + break; + case Key.Left: + ActiveCanvas?.RotateSelection(1); + e.Handled = true; + break; + case Key.Right: + ActiveCanvas?.RotateSelection(-1); + e.Handled = true; + break; + case Key.Up: + case Key.Down: + ActiveCanvas?.FlipSelection(); + e.Handled = true; + break; + } + } +} + +internal sealed class ComponentListItem +{ + public ComponentListItem(Type componentType) + { + ComponentType = componentType; + CircuitComponent component = (CircuitComponent)Activator.CreateInstance(componentType)!; + Name = component.TypeName; + Description = componentType.CustomAttribute()?.Description ?? componentType.Name; + } + + public Type ComponentType { get; } + + public string Name { get; } + + public string Description { get; } + + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/PluginEditorWindow.cs b/LiveSPICE.Avalonia/PluginEditorWindow.cs new file mode 100644 index 00000000..5897d4ae --- /dev/null +++ b/LiveSPICE.Avalonia/PluginEditorWindow.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Circuit; +using LiveSPICE.PluginCore; +using APoint = Avalonia.Point; + +namespace LiveSPICE.Avalonia; + +public sealed class PluginEditorWindow : Window +{ + private readonly SimulationProcessor processor; + private readonly SchematicCanvas schematicCanvas; + private readonly Canvas overlayCanvas; + private readonly StackPanel controlsPanel; + private readonly ComboBox oversampleBox; + private readonly ComboBox iterationsBox; + private readonly CheckBox autoReloadBox; + private readonly TextBlock loadedText; + private FileSystemWatcher? fileWatcher; + private string? loadedPath; + + public PluginEditorWindow() : this(new SimulationProcessor()) + { + } + + public PluginEditorWindow(SimulationProcessor processor) + { + this.processor = processor; + Title = "LiveSPICE Plugin"; + Width = 700; + Height = 420; + MinWidth = 520; + MinHeight = 320; + + schematicCanvas = new SchematicCanvas { IsHitTestVisible = false, Opacity = 0.45, Margin = new Thickness(0) }; + schematicCanvas.LayoutUpdated += (_, _) => PositionOverlayControls(); + overlayCanvas = new Canvas { IsHitTestVisible = true, Margin = new Thickness(0) }; + controlsPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center }; + oversampleBox = CreateOptionBox(new[] { 1, 2, 4, 8 }, processor.Oversample); + iterationsBox = CreateOptionBox(new[] { 1, 2, 4, 8, 16, 32, 64 }, processor.Iterations); + autoReloadBox = new CheckBox { Content = "Auto Reload", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }; + autoReloadBox.IsCheckedChanged += (_, _) => ConfigureAutoReload(); + loadedText = new TextBlock { Text = "Load Schematic", TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; + + Content = BuildLayout(); + RefreshFromProcessor(); + } + + public void LoadSchematic(string path) + { + processor.LoadSchematic(path); + loadedPath = path; + RefreshFromProcessor(); + } + + internal int TestOverlayControlCount => overlayCanvas.Children.Count; + + internal string TestLoadedText => loadedText.Text ?? string.Empty; + + internal int TestControlPanelCount => controlsPanel.Children.Count; + + internal object? TestSelectedOversample + { + get => oversampleBox.SelectedItem; + set => oversampleBox.SelectedItem = value; + } + + internal object? TestSelectedIterations + { + get => iterationsBox.SelectedItem; + set => iterationsBox.SelectedItem = value; + } + + internal APoint TestSchematicPointFor(IComponentWrapper wrapper) + { + return SchematicPointFor(wrapper); + } + + private Control BuildLayout() + { + Grid root = new Grid + { + Background = new SolidColorBrush(Color.FromRgb(72, 72, 72)), + RowDefinitions = new RowDefinitions("Auto,Auto,Auto,*"), + }; + + root.Children.Add(schematicCanvas); + Grid.SetRowSpan(schematicCanvas, 4); + root.Children.Add(overlayCanvas); + Grid.SetRowSpan(overlayCanvas, 4); + + Button loadButton = new Button { Content = loadedText, HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(14, 12, 14, 4) }; + loadButton.Click += LoadButton_Click; + root.Children.Add(loadButton); + + StackPanel commandRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(14, 4) }; + Button reloadButton = new Button { Content = "Reload" }; + reloadButton.Click += ReloadButton_Click; + Button viewButton = new Button { Content = "View" }; + viewButton.Click += ViewButton_Click; + Button aboutButton = new Button { Content = "About" }; + aboutButton.Click += AboutButton_Click; + commandRow.Children.Add(reloadButton); + commandRow.Children.Add(viewButton); + commandRow.Children.Add(aboutButton); + root.Children.Add(commandRow); + Grid.SetRow(commandRow, 1); + + StackPanel settingsRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(14, 4) }; + settingsRow.Children.Add(new TextBlock { Text = "Oversample", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }); + settingsRow.Children.Add(oversampleBox); + settingsRow.Children.Add(new TextBlock { Text = "Iterations", Foreground = Brushes.White, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }); + settingsRow.Children.Add(iterationsBox); + settingsRow.Children.Add(autoReloadBox); + root.Children.Add(settingsRow); + Grid.SetRow(settingsRow, 2); + + ScrollViewer controlsScroll = new ScrollViewer + { + Content = controlsPanel, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + Margin = new Thickness(14, 8, 14, 14), + }; + root.Children.Add(controlsScroll); + Grid.SetRow(controlsScroll, 3); + + return root; + } + + private static ComboBox CreateOptionBox(int[] values, int selected) + { + ComboBox comboBox = new ComboBox { Width = 76, ItemsSource = values.Cast().ToArray() }; + comboBox.SelectedItem = selected; + return comboBox; + } + + private async void LoadButton_Click(object? sender, RoutedEventArgs e) + { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("Circuit Schematics") { Patterns = new[] { "*.schx" } }, + FilePickerFileTypes.All, + }, + Title = "Open schematic", + }); + + string? path = files.FirstOrDefault()?.Path.LocalPath; + if (!string.IsNullOrEmpty(path)) + { + LoadSchematic(path); + ConfigureAutoReload(); + } + } + + private void ReloadButton_Click(object? sender, RoutedEventArgs e) + { + if (!string.IsNullOrEmpty(loadedPath) && File.Exists(loadedPath)) + LoadSchematic(loadedPath); + } + + private void ViewButton_Click(object? sender, RoutedEventArgs e) + { + if (processor.Schematic == null) + return; + + Window window = new Window + { + Title = processor.SchematicName, + Width = 1000, + Height = 720, + Content = new SchematicCanvas { Document = new SchematicDocument(processor.Schematic, loadedPath) }, + }; + window.Show(); + } + + private void AboutButton_Click(object? sender, RoutedEventArgs e) + { + Window about = new Window + { + Title = "About LiveSPICE Plugin", + Width = 320, + Height = 160, + Content = new TextBlock + { + Text = "LiveSPICE Linux Plugin\nlivespice.org", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center, + }, + }; + about.ShowDialog(this); + } + + private void RefreshFromProcessor() + { + loadedText.Text = processor.Schematic == null ? "Load Schematic" : processor.SchematicName; + schematicCanvas.Document = new SchematicDocument(processor.Schematic ?? new Schematic(), loadedPath); + oversampleBox.SelectionChanged -= OversampleBox_SelectionChanged; + iterationsBox.SelectionChanged -= IterationsBox_SelectionChanged; + oversampleBox.SelectedItem = processor.Oversample; + iterationsBox.SelectedItem = processor.Iterations; + oversampleBox.SelectionChanged += OversampleBox_SelectionChanged; + iterationsBox.SelectionChanged += IterationsBox_SelectionChanged; + RebuildControls(); + RebuildOverlayControls(); + } + + private void RebuildControls() + { + controlsPanel.Children.Clear(); + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + controlsPanel.Children.Add(CreateControl(wrapper)); + } + + private Control CreateControl(IComponentWrapper wrapper) + { + StackPanel panel = new StackPanel { Width = 96, Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center }; + panel.Children.Add(new TextBlock + { + Text = wrapper.Name, + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }); + + switch (wrapper) + { + case PotWrapper potWrapper: + Slider slider = new Slider { Minimum = 0, Maximum = 1, Value = potWrapper.PotValue, Width = 82 }; + slider.PropertyChanged += (_, args) => + { + if (args.Property == RangeBase.ValueProperty) + potWrapper.PotValue = slider.Value; + }; + panel.Children.Add(slider); + break; + case DoubleThrowWrapper doubleThrowWrapper: + ToggleButton toggle = new ToggleButton { IsChecked = doubleThrowWrapper.Engaged, HorizontalAlignment = HorizontalAlignment.Center }; + toggle.IsCheckedChanged += (_, _) => doubleThrowWrapper.Engaged = toggle.IsChecked == true; + panel.Children.Add(toggle); + break; + case MultiThrowWrapper multiThrowWrapper: + ComboBox comboBox = CreateOptionBox(Enumerable.Range(0, multiThrowWrapper.NumPositions).ToArray(), multiThrowWrapper.Position); + comboBox.SelectionChanged += (_, _) => + { + if (comboBox.SelectedItem is int position) + multiThrowWrapper.Position = position; + }; + panel.Children.Add(comboBox); + break; + } + + return panel; + } + + private void RebuildOverlayControls() + { + overlayCanvas.Children.Clear(); + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + { + Control control = CreateOverlayControl(wrapper); + control.Tag = wrapper; + overlayCanvas.Children.Add(control); + } + + PositionOverlayControls(); + } + + private Control CreateOverlayControl(IComponentWrapper wrapper) + { + Border shell = new Border + { + Background = new SolidColorBrush(Color.FromArgb(225, 42, 42, 42)), + BorderBrush = Brushes.White, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4), + MinWidth = 72, + Child = CreateControl(wrapper), + }; + return shell; + } + + private void PositionOverlayControls() + { + if (processor.Schematic == null) + return; + + foreach (Control control in overlayCanvas.Children.OfType()) + { + if (control.Tag is not IComponentWrapper wrapper) + continue; + + APoint point = SchematicPointFor(wrapper); + Canvas.SetLeft(control, point.X - control.Bounds.Width / 2); + Canvas.SetTop(control, point.Y - control.Bounds.Height / 2); + } + } + + private APoint SchematicPointFor(IComponentWrapper wrapper) + { + IEnumerable symbols = processor.Schematic?.Symbols.Where(i => WrapperMatchesSymbol(wrapper, i)) ?? Enumerable.Empty(); + if (!symbols.Any()) + return new APoint(16, 16); + + double x = symbols.Average(i => i.Position.x); + double y = symbols.Average(i => i.Position.y); + return schematicCanvas.SchematicToScreen(new Circuit.Point(x, y)); + } + + internal static bool WrapperMatchesSymbol(IComponentWrapper wrapper, Symbol symbol) + { + if (symbol.Component.Name == wrapper.Name) + return true; + + return symbol.Component switch + { + IPotControl pot => pot.Group == wrapper.Name, + IButtonControl button => button.Group == wrapper.Name, + _ => false, + }; + } + + private void ConfigureAutoReload() + { + fileWatcher?.Dispose(); + fileWatcher = null; + + if (autoReloadBox.IsChecked != true || string.IsNullOrEmpty(loadedPath)) + return; + + string? directory = Path.GetDirectoryName(loadedPath); + if (string.IsNullOrEmpty(directory)) + return; + + fileWatcher = new FileSystemWatcher + { + Filter = Path.GetFileName(loadedPath), + Path = directory, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite, + EnableRaisingEvents = true, + }; + fileWatcher.Changed += OnCircuitFileUpdate; + fileWatcher.Renamed += OnCircuitFileUpdate; + } + + private void OnCircuitFileUpdate(object sender, FileSystemEventArgs e) + { + if (!string.Equals(Path.GetFullPath(e.FullPath), Path.GetFullPath(loadedPath ?? string.Empty), StringComparison.OrdinalIgnoreCase)) + return; + + Dispatcher.UIThread.Post(() => + { + if (!string.IsNullOrEmpty(loadedPath) && File.Exists(loadedPath)) + LoadSchematic(loadedPath); + }); + } + + private void OversampleBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (oversampleBox.SelectedItem is int value) + processor.Oversample = value; + } + + private void IterationsBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (iterationsBox.SelectedItem is int value) + processor.Iterations = value; + } +} diff --git a/LiveSPICE.Avalonia/Program.cs b/LiveSPICE.Avalonia/Program.cs new file mode 100644 index 00000000..ab7ffcda --- /dev/null +++ b/LiveSPICE.Avalonia/Program.cs @@ -0,0 +1,22 @@ +using Avalonia; + +namespace LiveSPICE.Avalonia; + +internal static class Program +{ + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .With(new X11PlatformOptions + { + RenderingMode = new[] { X11RenderingMode.Software } + }) + .LogToTrace(); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/PropertyInspector.cs b/LiveSPICE.Avalonia/PropertyInspector.cs new file mode 100644 index 00000000..c02bc086 --- /dev/null +++ b/LiveSPICE.Avalonia/PropertyInspector.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Layout; +using Circuit; +using Util; + +namespace LiveSPICE.Avalonia; + +public sealed class PropertyInspector : ScrollViewer +{ + private readonly StackPanel panel = new StackPanel { Spacing = 6, Margin = new global::Avalonia.Thickness(8) }; + private IReadOnlyList selectedObjects = Array.Empty(); + + public PropertyInspector() + { + Content = panel; + } + + public event Action? PropertyChangedByUser; + + public void SetSelectedObject(object? value) + { + selectedObjects = value == null ? Array.Empty() : new[] { value }; + Rebuild(); + } + + public void SetSelectedObjects(IReadOnlyList values) + { + selectedObjects = values; + Rebuild(); + } + + private void Rebuild() + { + panel.Children.Clear(); + + if (selectedObjects.Count == 0) + { + panel.Children.Add(new TextBlock { Text = "No selection" }); + return; + } + + object first = selectedObjects[0]; + panel.Children.Add(new TextBlock { Text = selectedObjects.Count == 1 ? first.ToString() : selectedObjects.Count + " objects", FontWeight = global::Avalonia.Media.FontWeight.Bold }); + + if (selectedObjects.Any(i => i.GetType() != first.GetType())) + { + panel.Children.Add(new TextBlock { Text = "Mixed selection" }); + return; + } + + foreach (PropertyInfo property in EditableProperties(first)) + AddEditor(selectedObjects, property); + } + + private static IEnumerable EditableProperties(object instance) + { + return instance.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(i => i.CanRead && i.CanWrite) + .Where(i => i.CustomAttribute()?.Browsable != false) + .Where(i => i.CustomAttribute() != null || IsSimple(i.PropertyType)); + } + + private static bool IsSimple(Type type) + { + return type == typeof(string) || type == typeof(int) || type == typeof(double) || type == typeof(decimal) || type == typeof(bool) || type.IsEnum; + } + + private void AddEditor(IReadOnlyList targets, PropertyInfo property) + { + TextBlock label = new TextBlock + { + Text = property.Name, + VerticalAlignment = VerticalAlignment.Center + }; + + Control editor; + if (property.PropertyType == typeof(bool)) + { + CheckBox checkBox = new CheckBox { IsChecked = SharedValue(targets, property) as bool? }; + checkBox.IsCheckedChanged += (_, _) => SetValue(targets, property, checkBox.IsChecked == true); + editor = checkBox; + } + else + { + TextBox textBox = new TextBox { Text = ConvertToString(property, SharedValue(targets, property)) }; + textBox.LostFocus += (_, _) => SetValueFromString(targets, property, textBox.Text ?? ""); + textBox.KeyDown += (_, e) => + { + if (e.Key == global::Avalonia.Input.Key.Enter) + { + SetValueFromString(targets, property, textBox.Text ?? ""); + e.Handled = true; + } + }; + editor = textBox; + } + + Grid row = new Grid { ColumnDefinitions = new ColumnDefinitions("105,*") }; + row.Children.Add(label); + Grid.SetColumn(editor, 1); + row.Children.Add(editor); + panel.Children.Add(row); + } + + private static string ConvertToString(PropertyInfo property, object? value) + { + TypeConverter converter = TypeDescriptor.GetConverter(property.PropertyType); + return converter.ConvertToString(null, CultureInfo.InvariantCulture, value) ?? string.Empty; + } + + private static object? SharedValue(IReadOnlyList targets, PropertyInfo property) + { + object? first = property.GetValue(targets[0]); + return targets.All(i => Equals(property.GetValue(i), first)) ? first : null; + } + + private void SetValueFromString(IReadOnlyList targets, PropertyInfo property, string text) + { + try + { + TypeConverter converter = TypeDescriptor.GetConverter(property.PropertyType); + SetValue(targets, property, converter.ConvertFromString(null, CultureInfo.InvariantCulture, text)); + } + catch + { + Rebuild(); + } + } + + private void SetValue(IReadOnlyList targets, PropertyInfo property, object? value) + { + List before = targets.Select(i => property.GetValue(i)).ToList(); + if (before.All(i => Equals(i, value))) + return; + + PropertyChangedByUser?.Invoke(new PropertyChangeListAction(targets, property, before, value)); + Rebuild(); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/SavePromptWindow.cs b/LiveSPICE.Avalonia/SavePromptWindow.cs new file mode 100644 index 00000000..79b033d5 --- /dev/null +++ b/LiveSPICE.Avalonia/SavePromptWindow.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace LiveSPICE.Avalonia; + +public enum SaveChoice +{ + Save, + Discard, + Cancel +} + +public sealed class SavePromptWindow : Window +{ + private SaveChoice choice = SaveChoice.Cancel; + + private SavePromptWindow(string documentTitle) + { + Title = "Unsaved Changes"; + Width = 420; + Height = 170; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + + TextBlock message = new TextBlock + { + Text = $"Save changes to {documentTitle}?", + TextWrapping = TextWrapping.Wrap, + Margin = new global::Avalonia.Thickness(16, 16, 16, 10) + }; + + StackPanel buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(16) + }; + buttons.Children.Add(Button("Save", SaveChoice.Save)); + buttons.Children.Add(Button("Discard", SaveChoice.Discard)); + buttons.Children.Add(Button("Cancel", SaveChoice.Cancel)); + + DockPanel dock = new DockPanel(); + DockPanel.SetDock(buttons, Dock.Bottom); + dock.Children.Add(buttons); + dock.Children.Add(message); + Content = dock; + } + + public static async Task ShowAsync(Window owner, string documentTitle) + { + SavePromptWindow window = new SavePromptWindow(documentTitle); + await window.ShowDialog(owner); + return window.choice; + } + + private Button Button(string text, SaveChoice result) + { + Button button = new Button { Content = text, MinWidth = 86 }; + button.Click += (_, _) => + { + choice = result; + Close(); + }; + return button; + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/SchematicCanvas.cs b/LiveSPICE.Avalonia/SchematicCanvas.cs new file mode 100644 index 00000000..4359552f --- /dev/null +++ b/LiveSPICE.Avalonia/SchematicCanvas.cs @@ -0,0 +1,755 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Circuit; +using APoint = Avalonia.Point; +using AVector = Avalonia.Vector; + +namespace LiveSPICE.Avalonia; + +public sealed class SchematicCanvas : Control +{ + private const double GridSize = 10; + private const double TerminalSize = 3; + private static readonly Typeface TextTypeface = new Typeface("Inter"); + + private Schematic? schematic; + private APoint pan = new APoint(0, 0); + private APoint? dragStart; + private Coord? selectionStart; + private Coord? selectionCurrent; + private bool additiveSelection; + private Element[] selectionBase = Array.Empty(); + private Coord? moveStart; + private Coord moveDelta; + private Element[] moveElements = Array.Empty(); + private Coord? wireStart; + private bool wireMode; + private readonly HashSet selected = new HashSet(); + + public SchematicDocument? Document + { + get => document; + set + { + document = value; + Schematic = value?.Schematic; + } + } + + private SchematicDocument? document; + + public Component? PendingComponent { get; set; } + + public object? SelectedObject => selected.Count == 1 ? (selected.Single() is Symbol symbol ? symbol.Component : selected.Single()) : null; + + public IReadOnlyList SelectedObjects => selected.Select(i => i is Symbol symbol ? symbol.Component : (object)i).ToArray(); + + public event Action? SelectionChanged; + + public event Action? DocumentChanged; + + public Schematic? Schematic + { + get => schematic; + set + { + schematic = value; + FitToView(); + InvalidateVisual(); + } + } + + public double Zoom + { + get => zoom; + set + { + zoom = Math.Clamp(value, 0.1, 20); + InvalidateVisual(); + } + } + + private double zoom = 1; + + public SchematicCanvas() + { + ClipToBounds = true; + Focusable = true; + PointerPressed += OnPointerPressed; + PointerReleased += OnPointerReleased; + PointerMoved += OnPointerMoved; + PointerWheelChanged += OnPointerWheelChanged; + SizeChanged += (_, _) => FitToView(); + } + + public void FitToView() + { + if (schematic == null || Bounds.Width <= 0 || Bounds.Height <= 0) + return; + + Coord lower = schematic.LowerBound; + Coord upper = schematic.UpperBound; + double width = Math.Max(upper.x - lower.x, 200); + double height = Math.Max(upper.y - lower.y, 200); + double fit = Math.Min((Bounds.Width - 80) / width, (Bounds.Height - 80) / height); + + zoom = Math.Clamp(fit, 0.1, 8); + pan = new APoint( + Bounds.Width / 2 - (lower.x + width / 2) * zoom, + Bounds.Height / 2 - (lower.y + height / 2) * zoom); + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { + context.FillRectangle(Brushes.White, Bounds); + DrawGrid(context); + + if (schematic == null) + { + DrawCenteredText(context, "Open a .schx file to view a schematic"); + return; + } + + foreach (Wire wire in schematic.Wires) + DrawWire(context, wire); + + foreach (Symbol symbol in schematic.Symbols) + DrawSymbol(context, symbol); + + if (selectionStart.HasValue && selectionCurrent.HasValue) + { + Rect rect = RectFromPoints(ToScreen(selectionStart.Value), ToScreen(selectionCurrent.Value)); + context.DrawRectangle(new SolidColorBrush(Color.FromArgb(30, 30, 120, 240)), new Pen(Brushes.DodgerBlue, 1, DashStyle.Dash), rect); + } + } + + private void DrawGrid(DrawingContext context) + { + Pen minor = new Pen(new SolidColorBrush(Color.FromRgb(225, 225, 245)), 1); + Pen major = new Pen(new SolidColorBrush(Color.FromRgb(175, 175, 230)), 1); + double step = GridSize * Zoom; + + if (step < 4) + return; + + double startX = pan.X % step; + double startY = pan.Y % step; + for (double x = startX; x < Bounds.Width; x += step) + context.DrawLine(Math.Abs((x - pan.X) / step % 10) < 0.001 ? major : minor, new APoint(x, 0), new APoint(x, Bounds.Height)); + for (double y = startY; y < Bounds.Height; y += step) + context.DrawLine(Math.Abs((y - pan.Y) / step % 10) < 0.001 ? major : minor, new APoint(0, y), new APoint(Bounds.Width, y)); + } + + private void DrawWire(DrawingContext context, Wire wire) + { + Pen wirePen = new Pen(selected.Contains(wire) ? Brushes.DodgerBlue : Brushes.DarkBlue, Math.Max(1, Zoom)); + context.DrawLine(wirePen, ToScreen(wire.A), ToScreen(wire.B)); + DrawTerminal(context, ToScreen(wire.A), Brushes.DarkBlue); + DrawTerminal(context, ToScreen(wire.B), Brushes.DarkBlue); + } + + private void DrawSymbol(DrawingContext context, Symbol symbol) + { + SymbolLayout layout = symbol.Component.LayoutSymbol(); + Transform transform = new Transform(symbol, layout); + + foreach (SymbolLayout.Shape line in layout.Lines) + context.DrawLine(PenFor(line.Edge), ToScreen(transform.Apply(line.x1)), ToScreen(transform.Apply(line.x2))); + + foreach (SymbolLayout.Shape rectangle in layout.Rectangles) + { + Rect rect = RectFromPoints(ToScreen(transform.Apply(rectangle.x1)), ToScreen(transform.Apply(rectangle.x2))); + context.DrawRectangle(rectangle.Fill ? BrushFor(rectangle.Edge) : null, PenFor(rectangle.Edge), rect); + } + + foreach (SymbolLayout.Shape ellipse in layout.Ellipses) + { + Rect rect = RectFromPoints(ToScreen(transform.Apply(ellipse.x1)), ToScreen(transform.Apply(ellipse.x2))); + context.DrawEllipse(ellipse.Fill ? BrushFor(ellipse.Edge) : null, PenFor(ellipse.Edge), rect); + } + + foreach (SymbolLayout.Curve curve in layout.Curves) + DrawCurve(context, transform, curve); + + foreach (SymbolLayout.Arc arc in layout.Arcs) + DrawArc(context, transform, arc); + + foreach (SymbolLayout.Text text in layout.Texts) + DrawText(context, transform, text); + + foreach (Terminal terminal in layout.Terminals) + DrawTerminal(context, ToScreen(transform.Apply(layout.MapTerminal(terminal))), terminal.ConnectedTo == null ? Brushes.Red : Brushes.DarkBlue); + + if (selected.Contains(symbol)) + context.DrawRectangle(null, new Pen(Brushes.DodgerBlue, 1), RectFromPoints(ToScreen(symbol.LowerBound), ToScreen(symbol.UpperBound))); + } + + private void DrawCurve(DrawingContext context, Transform transform, SymbolLayout.Curve curve) + { + if (curve.x.Length < 2) + return; + + Pen pen = PenFor(curve.Edge); + APoint previous = ToScreen(transform.Apply(curve.x[0])); + for (int i = 1; i < curve.x.Length; i++) + { + APoint next = ToScreen(transform.Apply(curve.x[i])); + context.DrawLine(pen, previous, next); + previous = next; + } + } + + private void DrawArc(DrawingContext context, Transform transform, SymbolLayout.Arc arc) + { + const int segments = 24; + double start = arc.StartAngle; + double end = arc.EndAngle; + double sweep = end - start; + + if (arc.Direction == Direction.Clockwise && sweep < 0) + sweep += 2 * Math.PI; + else if (arc.Direction == Direction.Counterclockwise && sweep > 0) + sweep -= 2 * Math.PI; + + Pen pen = PenFor(arc.Type); + Circuit.Point previous = ArcPoint(arc, transform, start); + for (int i = 1; i <= segments; i++) + { + double angle = start + sweep * i / segments; + Circuit.Point next = ArcPoint(arc, transform, angle); + context.DrawLine(pen, ToScreen(previous), ToScreen(next)); + previous = next; + } + } + + private static Circuit.Point ArcPoint(SymbolLayout.Arc arc, Transform transform, double angle) + { + return transform.Apply(new Circuit.Point( + arc.Center.x + Math.Cos(angle) * arc.Radius, + arc.Center.y + Math.Sin(angle) * arc.Radius)); + } + + private void DrawText(DrawingContext context, Transform transform, SymbolLayout.Text text) + { + double size = text.Size switch + { + Circuit.Size.Small => 8, + Circuit.Size.Large => 14, + _ => 10 + }; + FormattedText formatted = new FormattedText(text.String, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, size * Zoom, Brushes.Black); + context.DrawText(formatted, TextOrigin(transform, text, formatted.Width, formatted.Height)); + } + + private APoint TextOrigin(Transform transform, SymbolLayout.Text text, double width, double height) + { + APoint point = ToScreen(transform.Apply(text.x)); + AVector p1 = TextOffset(transform, text.x, new Circuit.Point( + text.x.x - AlignmentFactor(text.HorizontalAlign), + text.x.y + (1 - AlignmentFactor(text.VerticalAlign)))); + AVector p2 = TextOffset(transform, text.x, new Circuit.Point( + text.x.x - (1 - AlignmentFactor(text.HorizontalAlign)), + text.x.y + AlignmentFactor(text.VerticalAlign))); + + p1 = new AVector(p1.X * width, p1.Y * height); + p2 = new AVector(p2.X * width, p2.Y * height); + + return new APoint( + Math.Min(point.X + p1.X, point.X - p2.X), + Math.Min(point.Y + p1.Y, point.Y - p2.Y)); + } + + private static AVector TextOffset(Transform transform, Circuit.Point origin, Circuit.Point target) + { + Circuit.Point transformedOrigin = transform.Apply(origin); + Circuit.Point transformedTarget = transform.Apply(target); + return new AVector(transformedTarget.x - transformedOrigin.x, transformedTarget.y - transformedOrigin.y); + } + + private static double AlignmentFactor(Alignment alignment) + { + return alignment switch + { + Alignment.Near => 0, + Alignment.Center => 0.5, + Alignment.Far => 1, + _ => 0 + }; + } + + private void DrawTerminal(DrawingContext context, APoint point, IBrush brush) + { + double half = Math.Max(TerminalSize * Zoom / 2, 2); + context.DrawRectangle(brush, null, new Rect(point.X - half, point.Y - half, half * 2, half * 2)); + } + + private void DrawCenteredText(DrawingContext context, string text) + { + FormattedText formatted = new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, 16, Brushes.DimGray); + context.DrawText(formatted, new APoint((Bounds.Width - formatted.Width) / 2, (Bounds.Height - formatted.Height) / 2)); + } + + private APoint ToScreen(Circuit.Point point) + { + return new APoint(point.x * Zoom + pan.X, point.y * Zoom + pan.Y); + } + + internal APoint SchematicToScreen(Circuit.Point point) + { + return ToScreen(point); + } + + private Coord ToSchematic(APoint point) + { + return Snap(new Coord((int)Math.Round((point.X - pan.X) / Zoom), (int)Math.Round((point.Y - pan.Y) / Zoom))); + } + + private static Coord Snap(Coord coord) + { + return new Coord((int)Math.Round(coord.x / GridSize) * (int)GridSize, (int)Math.Round(coord.y / GridSize) * (int)GridSize); + } + + private static Rect RectFromPoints(APoint a, APoint b) + { + return new Rect(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)); + } + + private Pen PenFor(EdgeType edge) + { + return new Pen(BrushFor(edge), Math.Max(1, Zoom)); + } + + private static IBrush BrushFor(EdgeType edge) + { + return edge switch + { + EdgeType.Wire => Brushes.DarkBlue, + EdgeType.Gray => Brushes.Gray, + EdgeType.Red => Brushes.Red, + EdgeType.Green => Brushes.LimeGreen, + EdgeType.Blue => Brushes.Blue, + EdgeType.Yellow => Brushes.Goldenrod, + EdgeType.Cyan => Brushes.Teal, + EdgeType.Magenta => Brushes.Magenta, + EdgeType.Orange => Brushes.Orange, + _ => Brushes.Black + }; + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + Focus(); + APoint point = e.GetPosition(this); + Coord at = ToSchematic(point); + PointerPointProperties properties = e.GetCurrentPoint(this).Properties; + + if (schematic == null) + return; + + if (PendingComponent != null) + { + Symbol symbol = new Symbol(PendingComponent.Clone()) { Position = at }; + document?.Do(new AddElementsAction(schematic, new[] { symbol })); + SetSelection(symbol); + PendingComponent = null; + MarkChanged(false); + return; + } + + if (wireMode) + { + if (!wireStart.HasValue) + { + wireStart = at; + } + else if (wireStart.Value != at) + { + Wire wire = new Wire(wireStart.Value, at); + document?.Do(new AddElementsAction(schematic, new[] { wire })); + SetSelection(wire); + wireStart = null; + wireMode = false; + MarkChanged(false); + } + return; + } + + if (properties.IsMiddleButtonPressed) + { + dragStart = point; + e.Pointer.Capture(this); + return; + } + + Element? hit = HitTest(at); + if (hit != null) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + if (!selected.Add(hit)) + selected.Remove(hit); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + else if (!selected.Contains(hit)) + { + SetSelection(hit); + } + moveStart = at; + moveDelta = new Coord(0, 0); + moveElements = selected.ToArray(); + } + else + { + selectionStart = at; + selectionCurrent = at; + additiveSelection = e.KeyModifiers.HasFlag(KeyModifiers.Control); + selectionBase = additiveSelection ? selected.ToArray() : Array.Empty(); + if (!additiveSelection) + selected.Clear(); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + e.Pointer.Capture(this); + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + dragStart = null; + if (selectionStart.HasValue && selectionCurrent.HasValue) + { + SelectRect(selectionStart.Value, selectionCurrent.Value); + selectionStart = null; + selectionCurrent = null; + additiveSelection = false; + selectionBase = Array.Empty(); + } + if (moveDelta != new Coord(0, 0) && moveElements.Length > 0) + { + document?.Record(new MoveElementsAction(moveElements, moveDelta)); + MarkChanged(false); + } + moveStart = null; + moveDelta = new Coord(0, 0); + moveElements = Array.Empty(); + e.Pointer.Capture(null); + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + APoint current = e.GetPosition(this); + if (selectionStart.HasValue) + { + selectionCurrent = ToSchematic(current); + HighlightRect(selectionStart.Value, selectionCurrent.Value); + InvalidateVisual(); + return; + } + + if (moveStart.HasValue && selected.Count > 0) + { + Coord at = ToSchematic(current); + Coord delta = at - moveStart.Value; + if (delta != new Coord(0, 0)) + { + foreach (Element element in moveElements) + element.Move(delta); + moveDelta += delta; + moveStart = at; + InvalidateVisual(); + } + return; + } + + if (dragStart != null) + { + AVector delta = current - dragStart.Value; + pan += delta; + dragStart = current; + InvalidateVisual(); + } + } + + private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + APoint focus = e.GetPosition(this); + double oldZoom = Zoom; + Zoom *= e.Delta.Y > 0 ? 1.1 : 1 / 1.1; + double ratio = Zoom / oldZoom; + pan = new APoint(focus.X - (focus.X - pan.X) * ratio, focus.Y - (focus.Y - pan.Y) * ratio); + } + + public void BeginWireTool() + { + PendingComponent = null; + wireMode = true; + wireStart = null; + } + + internal bool TestClick(Coord at, bool control = false) + { + Element? hit = HitTest(at); + if (hit == null) + { + if (!control) + ClearSelection(); + return false; + } + + if (control) + { + if (!selected.Add(hit)) + selected.Remove(hit); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + else + { + SetSelection(hit); + } + return true; + } + + internal void TestDragSelected(Coord from, Coord to) + { + if (selected.Count == 0) + TestClick(from); + if (selected.Count == 0) + return; + + Element[] moved = selected.ToArray(); + Coord delta = to - from; + if (delta == new Coord(0, 0)) + return; + + foreach (Element element in moved) + element.Move(delta); + document?.Record(new MoveElementsAction(moved, delta)); + MarkChanged(false); + } + + internal void TestDragSelect(Coord a, Coord b, bool control = false) + { + selectionBase = control ? selected.ToArray() : Array.Empty(); + HighlightRect(a, b); + selectionBase = Array.Empty(); + InvalidateVisual(); + } + + internal bool TestWireClick(Coord at) + { + if (schematic == null) + return false; + + if (!wireStart.HasValue) + { + BeginWireTool(); + wireStart = at; + return false; + } + + if (wireStart.Value == at) + return false; + + Wire wire = new Wire(wireStart.Value, at); + document?.Do(new AddElementsAction(schematic, new[] { wire })); + SetSelection(wire); + wireStart = null; + wireMode = false; + MarkChanged(false); + return true; + } + + internal static Coord TestTransformPoint(Symbol symbol, Circuit.Point point) + { + return (Coord)Circuit.Point.Round(new Transform(symbol, symbol.Component.LayoutSymbol()).Apply(point)); + } + + public void DeleteSelection() + { + if (schematic == null || selected.Count == 0) + return; + + Element[] remove = selected.ToArray(); + document?.Do(new RemoveElementsAction(schematic, remove)); + ClearSelection(); + MarkChanged(false); + } + + public string? CopySelectionXml() + { + if (selected.Count == 0) + return null; + + XElement root = new XElement("Schematic"); + foreach (Element element in selected) + root.Add(element.Serialize()); + return root.ToString(); + } + + public bool PasteSelectionXml(string xml) + { + if (schematic == null) + return false; + + try + { + List elements = XElement.Parse(xml).Elements("Element").Select(Element.Deserialize).ToList(); + if (elements.Count == 0) + return false; + + Coord offset = new Coord(20, 20); + foreach (Element element in elements) + element.Move(offset); + + document?.Do(new AddElementsAction(schematic, elements)); + selected.Clear(); + foreach (Element element in elements) + selected.Add(element); + MarkChanged(false); + return true; + } + catch + { + return false; + } + } + + public void SelectAll() + { + if (schematic == null) + return; + + selected.Clear(); + foreach (Element element in schematic.Elements) + selected.Add(element); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + public void RotateSelection(int delta) + { + if (selected.Count == 0) + return; + + Circuit.Point center = SelectionCenter(); + document?.Do(new RotateElementsAction(selected, delta, center)); + MarkChanged(false); + } + + public void FlipSelection() + { + if (selected.Count == 0) + return; + + double y = SelectionCenter().y; + document?.Do(new FlipElementsAction(selected, y)); + MarkChanged(false); + } + + private Circuit.Point SelectionCenter() + { + Coord lower = new Coord(selected.Min(i => i.LowerBound.x), selected.Min(i => i.LowerBound.y)); + Coord upper = new Coord(selected.Max(i => i.UpperBound.x), selected.Max(i => i.UpperBound.y)); + return (lower + upper) / 2; + } + + private Element? HitTest(Coord at) + { + if (schematic == null) + return null; + + return schematic.Elements.Reverse().FirstOrDefault(i => i.Intersects(at - 4, at + 4)); + } + + private void HighlightRect(Coord a, Coord b) + { + if (schematic == null) + return; + + selected.Clear(); + foreach (Element element in selectionBase) + selected.Add(element); + foreach (Element element in ElementsInRect(a, b)) + selected.Add(element); + SelectionChanged?.Invoke(); + } + + private void SelectRect(Coord a, Coord b) + { + HighlightRect(a, b); + InvalidateVisual(); + } + + private IEnumerable ElementsInRect(Coord a, Coord b) + { + if (schematic == null) + return Array.Empty(); + + Coord lower = new Coord(Math.Min(a.x, b.x), Math.Min(a.y, b.y)); + Coord upper = new Coord(Math.Max(a.x, b.x), Math.Max(a.y, b.y)); + return schematic.Elements.Where(i => i.Intersects(lower, upper)).ToArray(); + } + + private void SetSelection(Element element) + { + selected.Clear(); + selected.Add(element); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + private void ClearSelection() + { + selected.Clear(); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + private void MarkChanged(bool markDirty = true) + { + if (markDirty) + document?.MarkDirty(); + DocumentChanged?.Invoke(); + SelectionChanged?.Invoke(); + InvalidateVisual(); + } + + private readonly struct Transform + { + private readonly Symbol symbol; + private readonly SymbolLayout layout; + + public Transform(Symbol symbol, SymbolLayout layout) + { + this.symbol = symbol; + this.layout = layout; + } + + public Circuit.Point Apply(Circuit.Point local) + { + double x = local.x; + double y = local.y; + if (!symbol.Flip) + y = -y; + + int rotation = ((symbol.Rotation % 4) + 4) % 4; + (x, y) = rotation switch + { + 1 => (y, -x), + 2 => (-x, -y), + 3 => (-y, x), + _ => (x, y) + }; + + return new Circuit.Point(x + symbol.Position.x, y + symbol.Position.y); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/SchematicDocument.cs b/LiveSPICE.Avalonia/SchematicDocument.cs new file mode 100644 index 00000000..50e2dd9a --- /dev/null +++ b/LiveSPICE.Avalonia/SchematicDocument.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Circuit; + +namespace LiveSPICE.Avalonia; + +public sealed class SchematicDocument +{ + public SchematicDocument(Schematic schematic, string? filePath = null) + { + Schematic = schematic; + FilePath = filePath; + UpdateSavedWriteTime(); + } + + public Schematic Schematic { get; } + + public string? FilePath { get; private set; } + + public bool Dirty { get; private set; } + + private DateTime savedWriteTimeUtc; + + private readonly Stack undo = new Stack(); + private readonly Stack redo = new Stack(); + + public string Title + { + get + { + string title = FilePath == null ? "" : Path.GetFileNameWithoutExtension(FilePath); + return Dirty ? title + " *" : title; + } + } + + public static SchematicDocument New() + { + return new SchematicDocument(new Schematic()); + } + + public static SchematicDocument Open(string path) + { + return new SchematicDocument(Schematic.Load(path), path); + } + + public void MarkDirty() + { + Dirty = true; + } + + public bool CanUndo => undo.Count > 0; + + public bool CanRedo => redo.Count > 0; + + public void Do(IEditAction action) + { + action.Do(); + Record(action); + } + + public void Record(IEditAction action) + { + undo.Push(action); + redo.Clear(); + MarkDirty(); + } + + public void Undo() + { + if (undo.Count == 0) + return; + + IEditAction action = undo.Pop(); + action.Undo(); + redo.Push(action); + MarkDirty(); + } + + public void Redo() + { + if (redo.Count == 0) + return; + + IEditAction action = redo.Pop(); + action.Do(); + undo.Push(action); + MarkDirty(); + } + + public void Save(string path) + { + Schematic.Save(path); + FilePath = path; + Dirty = false; + UpdateSavedWriteTime(); + } + + public bool WasModifiedExternally() + { + if (FilePath == null || Dirty || !File.Exists(FilePath)) + return false; + + return File.GetLastWriteTimeUtc(FilePath) != savedWriteTimeUtc; + } + + private void UpdateSavedWriteTime() + { + savedWriteTimeUtc = FilePath != null && File.Exists(FilePath) ? File.GetLastWriteTimeUtc(FilePath) : DateTime.MinValue; + } +} + +public interface IEditAction +{ + void Do(); + + void Undo(); +} + +public sealed class AddElementsAction : IEditAction +{ + private readonly Schematic schematic; + private readonly List elements; + + public AddElementsAction(Schematic schematic, IEnumerable elements) + { + this.schematic = schematic; + this.elements = elements.ToList(); + } + + public IReadOnlyList Elements => elements; + + public void Do() => schematic.Add(elements); + + public void Undo() => schematic.Remove(elements); +} + +public sealed class RemoveElementsAction : IEditAction +{ + private readonly Schematic schematic; + private readonly List elements; + + public RemoveElementsAction(Schematic schematic, IEnumerable elements) + { + this.schematic = schematic; + this.elements = elements.ToList(); + } + + public void Do() => schematic.Remove(elements); + + public void Undo() => schematic.Add(elements); +} + +public sealed class MoveElementsAction : IEditAction +{ + private readonly List elements; + private readonly Coord delta; + + public MoveElementsAction(IEnumerable elements, Coord delta) + { + this.elements = elements.ToList(); + this.delta = delta; + } + + public void Do() + { + foreach (Element element in elements) + element.Move(delta); + } + + public void Undo() + { + foreach (Element element in elements) + element.Move(-delta); + } +} + +public sealed class RotateElementsAction : IEditAction +{ + private readonly List elements; + private readonly int delta; + private readonly Point center; + + public RotateElementsAction(IEnumerable elements, int delta, Point center) + { + this.elements = elements.ToList(); + this.delta = delta; + this.center = center; + } + + public void Do() + { + foreach (Element element in elements) + element.RotateAround(delta, center); + } + + public void Undo() + { + foreach (Element element in elements) + element.RotateAround(-delta, center); + } +} + +public sealed class FlipElementsAction : IEditAction +{ + private readonly List elements; + private readonly double y; + + public FlipElementsAction(IEnumerable elements, double y) + { + this.elements = elements.ToList(); + this.y = y; + } + + public void Do() + { + foreach (Element element in elements) + element.FlipOver(y); + } + + public void Undo() => Do(); +} + +public sealed class PropertyChangeAction : IEditAction +{ + private readonly object target; + private readonly PropertyInfo property; + private readonly object? before; + private readonly object? after; + + public PropertyChangeAction(object target, PropertyInfo property, object? before, object? after) + { + this.target = target; + this.property = property; + this.before = before; + this.after = after; + } + + public void Do() => property.SetValue(target, after); + + public void Undo() => property.SetValue(target, before); +} + +public sealed class PropertyChangeListAction : IEditAction +{ + private readonly List targets; + private readonly PropertyInfo property; + private readonly List before; + private readonly object? after; + + public PropertyChangeListAction(IEnumerable targets, PropertyInfo property, IEnumerable before, object? after) + { + this.targets = targets.ToList(); + this.property = property; + this.before = before.ToList(); + this.after = after; + } + + public void Do() + { + foreach (object target in targets) + property.SetValue(target, after); + } + + public void Undo() + { + for (int i = 0; i < targets.Count; i++) + property.SetValue(targets[i], before[i]); + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/VirtualAudioDriver.cs b/LiveSPICE.Avalonia/VirtualAudioDriver.cs new file mode 100644 index 00000000..63c543fb --- /dev/null +++ b/LiveSPICE.Avalonia/VirtualAudioDriver.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; + +namespace LiveSPICE.Avalonia; + +public sealed class VirtualAudioDriver : Audio.Driver +{ + public VirtualAudioDriver() + { + devices.Add(new VirtualAudioDevice()); + } + + public override string Name => "LiveSPICE Virtual Audio"; + + public override string ToString() + { + return Name; + } +} + +internal sealed class VirtualAudioChannel : Audio.Channel +{ + private readonly string name; + + public VirtualAudioChannel(string name) + { + this.name = name; + } + + public override string Name => name; + + public override string ToString() + { + return name; + } +} + +internal sealed class VirtualAudioDevice : Audio.Device +{ + public VirtualAudioDevice() : base("Managed loopback") + { + inputs = new Audio.Channel[] { new VirtualAudioChannel("Input 1") }; + outputs = new Audio.Channel[] { new VirtualAudioChannel("Output 1") }; + } + + public override string ToString() + { + return Name; + } + + public override Audio.Stream Open(Audio.Stream.SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) + { + return new VirtualAudioStream(callback, input, output); + } +} + +internal sealed class VirtualAudioStream : Audio.Stream +{ + private const int Rate = 48000; + private const int BufferSize = 256; + private readonly SampleHandler callback; + private readonly Thread thread; + private volatile bool stopped; + + public VirtualAudioStream(SampleHandler callback, Audio.Channel[] input, Audio.Channel[] output) : base(input, output) + { + this.callback = callback; + thread = new Thread(Run) { Name = "Virtual Audio Stream", IsBackground = true }; + thread.Start(); + } + + public override double SampleRate => Rate; + + public override void Stop() + { + stopped = true; + thread.Join(); + } + + private void Run() + { + using Audio.SampleBuffer input = new Audio.SampleBuffer(BufferSize); + using Audio.SampleBuffer output = new Audio.SampleBuffer(BufferSize); + Audio.SampleBuffer[] inputBuffers = InputChannels.Length == 0 ? Array.Empty() : new[] { input }; + Audio.SampleBuffer[] outputBuffers = OutputChannels.Length == 0 ? Array.Empty() : new[] { output }; + TimeSpan interval = TimeSpan.FromSeconds(BufferSize / (double)Rate); + + while (!stopped) + { + input.Clear(); + output.Clear(); + callback(BufferSize, inputBuffers, outputBuffers, Rate); + Thread.Sleep(interval); + } + } +} \ No newline at end of file diff --git a/LiveSPICE.Avalonia/WaveformWindow.cs b/LiveSPICE.Avalonia/WaveformWindow.cs new file mode 100644 index 00000000..df75c8f9 --- /dev/null +++ b/LiveSPICE.Avalonia/WaveformWindow.cs @@ -0,0 +1,448 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Circuit; +using ComputerAlgebra; +using ComputerAlgebra.LinqCompiler; +using APoint = Avalonia.Point; + +namespace LiveSPICE.Avalonia; + +public sealed class WaveformWindow : Window +{ + private readonly Schematic schematic; + private readonly AppSettings settings; + private readonly WaveformView waveform = new WaveformView(); + private readonly TextBox oversample = new TextBox { Text = "8", Width = 52 }; + private readonly TextBox iterations = new TextBox { Text = "8", Width = 52 }; + private readonly TextBox samples = new TextBox { Text = "4096", Width = 72 }; + private readonly TextBox frequency = new TextBox { Text = "440", Width = 72 }; + private readonly TextBox inputExpression = new TextBox { Watermark = "Optional expression in t", MinWidth = 160 }; + private readonly Slider inputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; + private readonly Slider outputGain = new Slider { Minimum = -40, Maximum = 40, Value = 0, Width = 160 }; + private readonly ListBox probeList = new ListBox { SelectionMode = SelectionMode.Multiple, MinHeight = 96, MaxHeight = 180 }; + private readonly TextBlock audioConfig = new TextBlock { TextWrapping = TextWrapping.Wrap }; + private readonly TextBox log = new TextBox { IsReadOnly = true, AcceptsReturn = true, MinHeight = 100, TextWrapping = TextWrapping.Wrap }; + private readonly LiveAudioProcessor liveProcessor; + private double liveInputScale = 1; + private double liveOutputScale = 1; + private Audio.Stream? liveStream; + + public WaveformWindow(Schematic schematic, AppSettings settings) + { + this.schematic = schematic; + this.settings = settings; + liveProcessor = new LiveAudioProcessor(schematic); + Title = "Simulation Scope"; + Width = 1000; + Height = 700; + MinWidth = 520; + MinHeight = 420; + Content = BuildContent(); + UpdateAudioSummary(); + RunSimulation(); + Closed += (_, _) => StopLive(); + } + + private Control BuildContent() + { + DockPanel root = new DockPanel(); + + StackPanel toolbar = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Margin = new global::Avalonia.Thickness(8), + VerticalAlignment = VerticalAlignment.Center + }; + toolbar.Children.Add(Label("Oversample")); + toolbar.Children.Add(oversample); + toolbar.Children.Add(Label("Iterations")); + toolbar.Children.Add(iterations); + toolbar.Children.Add(Label("Samples")); + toolbar.Children.Add(samples); + toolbar.Children.Add(Label("Hz")); + toolbar.Children.Add(frequency); + toolbar.Children.Add(Label("Input")); + toolbar.Children.Add(inputExpression); + toolbar.Children.Add(Button("Run", (_, _) => RunSimulation())); + toolbar.Children.Add(Button("Live", (_, _) => ToggleLive())); + DockPanel.SetDock(toolbar, Dock.Top); + root.Children.Add(toolbar); + + Grid main = new Grid { ColumnDefinitions = new ColumnDefinitions("220,*"), RowDefinitions = new RowDefinitions("*,150") }; + + StackPanel audio = new StackPanel { Spacing = 10, Margin = new global::Avalonia.Thickness(10) }; + inputGain.PropertyChanged += (_, e) => { if (e.Property == Slider.ValueProperty) liveInputScale = DbToLinear(inputGain.Value); }; + outputGain.PropertyChanged += (_, e) => { if (e.Property == Slider.ValueProperty) liveOutputScale = DbToLinear(outputGain.Value); }; + audio.Children.Add(Header("Audio")); + audio.Children.Add(audioConfig); + audio.Children.Add(Button("Configure", (_, _) => + { + AudioConfigWindow window = new AudioConfigWindow(settings); + window.Closed += (_, _) => UpdateAudioSummary(); + window.Show(); + })); + audio.Children.Add(Label("Input gain (dB)")); + audio.Children.Add(inputGain); + audio.Children.Add(Label("Output gain (dB)")); + audio.Children.Add(outputGain); + audio.Children.Add(Header("Input")); + audio.Children.Add(new TextBlock { Text = "Generated sine" }); + audio.Children.Add(Header("Output")); + audio.Children.Add(new TextBlock { Text = "First output channel" }); + audio.Children.Add(Header("Probes")); + audio.Children.Add(probeList); + Grid.SetColumn(audio, 0); + Grid.SetRowSpan(audio, 2); + main.Children.Add(audio); + + Border plotBorder = new Border { BorderBrush = Brushes.LightGray, BorderThickness = new global::Avalonia.Thickness(1), Child = waveform }; + Grid.SetColumn(plotBorder, 1); + Grid.SetRow(plotBorder, 0); + main.Children.Add(plotBorder); + + log.Margin = new global::Avalonia.Thickness(8); + Grid.SetColumn(log, 1); + Grid.SetRow(log, 1); + main.Children.Add(log); + + root.Children.Add(main); + return root; + } + + private void RunSimulation() + { + try + { + int oversampleValue = ParseInt(oversample.Text, 8, 1, 64); + int iterationValue = ParseInt(iterations.Text, 8, 1, 64); + int sampleCount = ParseInt(samples.Text, 4096, 64, 262144); + double frequencyValue = ParseDouble(frequency.Text, 440, 0.1, 96000); + int sampleRate = 48000; + + Circuit.Circuit circuit = schematic.Build(); + RefreshProbeCandidates(circuit); + Expression inputExpression = circuit.Components.OfType().Select(i => i.In).SingleOrDefault() + ?? throw new NotSupportedException("Circuit has no inputs."); + Expression speakerOutput = SpeakerOutputExpression(circuit); + ProbeCandidate[] selectedProbes = SelectedProbeCandidates().ToArray(); + Expression[] outputExpressions = new[] { speakerOutput }.Concat(selectedProbes.Select(i => i.Expression)).ToArray(); + Simulation simulation = new Simulation(AudioSimulationFactory.Solve(circuit, sampleRate, oversampleValue)) + { + Input = new[] { inputExpression }, + Output = outputExpressions, + Oversample = oversampleValue, + Iterations = iterationValue, + }; + + double inGain = DbToLinear(inputGain.Value); + double outGain = DbToLinear(outputGain.Value); + double[] input = new double[sampleCount]; + string[] outputNames = new[] { settings.AudioOutputs.Count == 0 ? "Speaker output" : string.Join(" + ", settings.AudioOutputs) } + .Concat(selectedProbes.Select(i => i.Name)) + .ToArray(); + double[][] outputs = outputNames.Select(_ => new double[sampleCount]).ToArray(); + FillInputBuffer(input, sampleRate, frequencyValue, inGain); + + simulation.Run(input.Length, new[] { input }, outputs); + for (int channel = 0; channel < outputs.Length; channel++) + { + for (int i = 0; i < outputs[channel].Length; i++) + outputs[channel][i] *= outGain; + } + + waveform.SetTraces(outputNames.Zip(outputs, (name, data) => new WaveformTrace(name, data)), sampleRate); + log.Text = $"Build succeeded\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {AudioName(settings.AudioDevice)}\nInputs: {ChannelNames(settings.AudioInputs)}\nOutputs: {ChannelNames(settings.AudioOutputs)}\nProbes: {ChannelNames(selectedProbes.Select(i => i.Name).ToArray())}\nInput signal: {InputDescription(frequencyValue)}\nSample rate: {sampleRate}\nOversample: {oversampleValue}\nIterations: {iterationValue}\nSamples: {sampleCount}\nPeak: {outputs.SelectMany(i => i).Select(Math.Abs).DefaultIfEmpty().Max():R}"; + } + catch (Exception ex) + { + log.Text = ex.ToString(); + } + } + + private void ToggleLive() + { + if (liveStream != null) + { + StopLive(); + return; + } + + try + { + Audio.Device device = SelectedDevice(); + Audio.Channel[] input = SelectedChannels(device.InputChannels, settings.AudioInputs).ToArray(); + Audio.Channel[] output = SelectedChannels(device.OutputChannels, settings.AudioOutputs).ToArray(); + if (input.Length == 0 && device.InputChannels.Length > 0) + input = new[] { device.InputChannels[0] }; + if (output.Length == 0 && device.OutputChannels.Length > 0) + output = new[] { device.OutputChannels[0] }; + + int oversampleValue = ParseInt(oversample.Text, 8, 1, 64); + int iterationValue = ParseInt(iterations.Text, 8, 1, 64); + liveInputScale = DbToLinear(inputGain.Value); + liveOutputScale = DbToLinear(outputGain.Value); + StartLiveSimulation(48000, oversampleValue, iterationValue); + + liveStream = device.Open(ProcessLiveSamples, input, output); + StartLiveSimulation(liveStream.SampleRate, oversampleValue, iterationValue); + log.Text = $"Live stream started\nAudio driver: {AudioName(settings.AudioDriver)}\nDevice: {device.Name}\nSample rate: {liveStream.SampleRate:0}"; + } + catch (Exception ex) + { + StopLive(); + log.Text = ex.ToString(); + } + } + + private void StopLive() + { + Audio.Stream? stream = liveStream; + liveStream = null; + if (stream != null) + stream.Stop(); + liveProcessor.Stop(); + } + + private void ProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + try + { + liveProcessor.InputScale = liveInputScale; + liveProcessor.OutputScale = liveOutputScale; + double[] outputSamples = liveProcessor.Process(count, input, output, rate); + + if (outputSamples.Length > 0) + Dispatcher.UIThread.Post(() => waveform.SetSamples(outputSamples, (int)rate)); + } + catch (Exception ex) + { + foreach (Audio.SampleBuffer buffer in output) + buffer.Clear(); + Dispatcher.UIThread.Post(() => log.Text = ex.ToString()); + } + } + + internal void StartLiveSimulation(double sampleRate, int oversampleValue = 8, int iterationValue = 8) + { + liveInputScale = DbToLinear(inputGain.Value); + liveOutputScale = DbToLinear(outputGain.Value); + liveProcessor.InputScale = liveInputScale; + liveProcessor.OutputScale = liveOutputScale; + liveProcessor.Start(sampleRate, oversampleValue, iterationValue); + } + + internal void TestProcessLiveSamples(int count, Audio.SampleBuffer[] input, Audio.SampleBuffer[] output, double rate) + { + ProcessLiveSamples(count, input, output, rate); + } + + private Audio.Device SelectedDevice() + { + IReadOnlyList drivers = AvaloniaAudioDrivers.Available(); + Audio.Driver driver = drivers.FirstOrDefault(i => i.Name == settings.AudioDriver && i.Devices.Any()) ?? drivers.First(i => i.Devices.Any()); + return driver.Devices.FirstOrDefault(i => i.Name == settings.AudioDevice) ?? driver.Devices.First(); + } + + private static System.Collections.Generic.IEnumerable SelectedChannels(Audio.Channel[] channels, System.Collections.Generic.IEnumerable names) + { + System.Collections.Generic.HashSet selected = names.ToHashSet(StringComparer.OrdinalIgnoreCase); + return channels.Where(i => selected.Contains(i.Name)); + } + + private static TextBlock Header(string text) + { + return new TextBlock { Text = text, FontWeight = FontWeight.Bold, Margin = new global::Avalonia.Thickness(0, 8, 0, 0) }; + } + + private void UpdateAudioSummary() + { + audioConfig.Text = $"{AudioName(settings.AudioDriver)} / {AudioName(settings.AudioDevice)}\nIn: {ChannelNames(settings.AudioInputs)}\nOut: {ChannelNames(settings.AudioOutputs)}"; + } + + private static string AudioName(string value) + { + return string.IsNullOrWhiteSpace(value) ? "None" : value; + } + + private static string ChannelNames(System.Collections.Generic.IReadOnlyCollection channels) + { + return channels.Count == 0 ? "None" : string.Join(", ", channels); + } + + private static TextBlock Label(string text) + { + return new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center }; + } + + private static Button Button(string text, EventHandler click) + { + Button button = new Button { Content = text, MinWidth = 70 }; + button.Click += click; + return button; + } + + private static int ParseInt(string? text, int fallback, int min, int max) + { + return int.TryParse(text, out int value) ? Math.Clamp(value, min, max) : fallback; + } + + private static double ParseDouble(string? text, double fallback, double min, double max) + { + return double.TryParse(text, out double value) ? Math.Clamp(value, min, max) : fallback; + } + + private static double DbToLinear(double db) + { + return Math.Pow(10, db / 20); + } + + private void FillInputBuffer(double[] input, int sampleRate, double frequencyValue, double gain) + { + FillInputBuffer(input, sampleRate, frequencyValue, gain, inputExpression.Text); + } + + internal static void FillInputBuffer(double[] input, int sampleRate, double frequencyValue, double gain, string? expressionText) + { + Func? signal = CompileInputExpression(expressionText); + for (int i = 0; i < input.Length; i++) + { + double time = i / (double)sampleRate; + double value = signal == null ? 0.25 * Math.Sin(2 * Math.PI * frequencyValue * time) : signal(time); + input[i] = gain * value; + } + } + + private static Func? CompileInputExpression(string? expressionText) + { + if (string.IsNullOrWhiteSpace(expressionText)) + return null; + + Expression expression = Expression.Parse(expressionText); + return expression.Compile>(Circuit.Component.t); + } + + private string InputDescription(double frequencyValue) + { + return string.IsNullOrWhiteSpace(inputExpression.Text) ? $"Generated sine {frequencyValue} Hz" : inputExpression.Text; + } + + private void RefreshProbeCandidates(Circuit.Circuit circuit) + { + string[] selected = SelectedProbeCandidates().Select(i => i.Name).ToArray(); + ProbeCandidate[] candidates = ProbeCandidates(circuit).ToArray(); + probeList.ItemsSource = candidates; + if (probeList.SelectedItems == null) + return; + + probeList.SelectedItems.Clear(); + foreach (ProbeCandidate candidate in candidates.Where(i => selected.Contains(i.Name, StringComparer.OrdinalIgnoreCase))) + probeList.SelectedItems.Add(candidate); + } + + private System.Collections.Generic.IEnumerable SelectedProbeCandidates() + { + return probeList.SelectedItems?.OfType() ?? Enumerable.Empty(); + } + + internal static System.Collections.Generic.IEnumerable ProbeCandidates(Circuit.Circuit circuit) + { + return circuit.Nodes + .Where(i => !string.IsNullOrWhiteSpace(i.Name) && i.Name != "0") + .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(i => new ProbeCandidate(i.Name, i.V)); + } + + private static Expression SpeakerOutputExpression(Circuit.Circuit circuit) + { + Expression expression = 0; + foreach (Speaker speaker in circuit.Components.OfType()) + expression += speaker.Out; + if (expression.EqualsZero()) + throw new NotSupportedException("Circuit has no speaker outputs."); + return expression; + } +} + +public sealed class WaveformView : Control +{ + private static readonly Typeface TextTypeface = new Typeface("Inter"); + private static readonly IBrush[] TraceBrushes = { Brushes.DodgerBlue, Brushes.OrangeRed, Brushes.SeaGreen, Brushes.MediumPurple }; + private WaveformTrace[] traces = Array.Empty(); + private int sampleRate = 48000; + + public void SetSamples(double[] value, int rate) + { + SetTraces(new[] { new WaveformTrace("Output", value) }, rate); + } + + public void SetTraces(System.Collections.Generic.IEnumerable value, int rate) + { + traces = value.ToArray(); + sampleRate = rate; + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { + context.FillRectangle(Brushes.White, Bounds); + Rect plot = new Rect(48, 28, Math.Max(1, Bounds.Width - 72), Math.Max(1, Bounds.Height - 72)); + context.DrawRectangle(null, new Pen(Brushes.LightGray, 1), plot); + context.DrawLine(new Pen(Brushes.Gray, 1), new APoint(plot.Left, plot.Center.Y), new APoint(plot.Right, plot.Center.Y)); + + if (traces.Length == 0 || traces.All(i => i.Samples.Length == 0)) + return; + + double peak = Math.Max(traces.SelectMany(i => i.Samples).Select(Math.Abs).DefaultIfEmpty().Max(), 1e-9); + int maxSamples = traces.Max(i => i.Samples.Length); + for (int traceIndex = 0; traceIndex < traces.Length; traceIndex++) + { + double[] samples = traces[traceIndex].Samples; + if (samples.Length == 0) + continue; + + Pen waveform = new Pen(TraceBrushes[traceIndex % TraceBrushes.Length], 1.4); + APoint previous = SamplePoint(samples, 0, peak, plot); + for (int i = 1; i < samples.Length; i++) + { + APoint next = SamplePoint(samples, i, peak, plot); + context.DrawLine(waveform, previous, next); + previous = next; + } + DrawText(context, traces[traceIndex].Name, new APoint(plot.Right - 130, plot.Top + traceIndex * 16), TraceBrushes[traceIndex % TraceBrushes.Length]); + } + + DrawText(context, $"{maxSamples / (double)sampleRate:0.000}s peak {peak:0.000000}", new APoint(48, 6), Brushes.DimGray); + DrawText(context, "+peak", new APoint(6, plot.Top), Brushes.DimGray); + DrawText(context, "0", new APoint(24, plot.Center.Y - 8), Brushes.DimGray); + DrawText(context, "-peak", new APoint(6, plot.Bottom - 16), Brushes.DimGray); + } + + private static APoint SamplePoint(double[] samples, int index, double peak, Rect plot) + { + double x = plot.Left + (samples.Length == 1 ? 0 : index * plot.Width / (samples.Length - 1)); + double y = plot.Center.Y - samples[index] / peak * plot.Height / 2; + return new APoint(x, y); + } + + private static void DrawText(DrawingContext context, string text, APoint point, IBrush brush) + { + FormattedText formatted = new FormattedText(text, System.Globalization.CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, TextTypeface, 12, brush); + context.DrawText(formatted, point); + } +} + +public sealed record WaveformTrace(string Name, double[] Samples); + +public sealed record ProbeCandidate(string Name, Expression Expression) +{ + public override string ToString() + { + return Name; + } +} \ 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.Linux.sln b/LiveSPICE.Linux.sln new file mode 100644 index 00000000..3905170c --- /dev/null +++ b/LiveSPICE.Linux.sln @@ -0,0 +1,75 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Audio", "Audio\Audio.csproj", "{15F77DB4-1212-4412-871D-DF11022894A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Circuit", "Circuit\Circuit.csproj", "{3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ComputerAlgebra", "ComputerAlgebra", "{1D100B7C-E8B4-4909-878B-9F1EA4D8225D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComputerAlgebra", "ComputerAlgebra\ComputerAlgebra\ComputerAlgebra.csproj", "{B38B8038-EBD1-4326-A01D-5AA1B7275A99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Util", "Util\Util.csproj", "{E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Headless", "LiveSPICE.Headless\LiveSPICE.Headless.csproj", "{F70E548C-E2F1-4AF1-A841-A6626B180231}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginCore", "LiveSPICE.PluginCore\LiveSPICE.PluginCore.csproj", "{C039AC03-B5E7-43C4-9C52-F59910B1C5C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia", "LiveSPICE.Avalonia\LiveSPICE.Avalonia.csproj", "{4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.PluginLinux", "LiveSPICE.PluginLinux\LiveSPICE.PluginLinux.csproj", "{A32524C7-1B55-4108-A60E-0A60DE51638F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.Avalonia.Tests", "LiveSPICE.Avalonia.Tests\LiveSPICE.Avalonia.Tests.csproj", "{0ED01072-3C08-4934-BC3D-904C0D47B6AB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {15F77DB4-1212-4412-871D-DF11022894A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15F77DB4-1212-4412-871D-DF11022894A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15F77DB4-1212-4412-871D-DF11022894A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15F77DB4-1212-4412-871D-DF11022894A2}.Release|Any CPU.Build.0 = Release|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BFEAAE7-2E8B-44D2-82DC-8E8093ED46A9}.Release|Any CPU.Build.0 = Release|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B38B8038-EBD1-4326-A01D-5AA1B7275A99}.Release|Any CPU.Build.0 = Release|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4A9AF8A-A0A7-46DE-81F1-B2681FB3B634}.Release|Any CPU.Build.0 = Release|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F70E548C-E2F1-4AF1-A841-A6626B180231}.Release|Any CPU.Build.0 = Release|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C039AC03-B5E7-43C4-9C52-F59910B1C5C1}.Release|Any CPU.Build.0 = Release|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EB9F067-DBAD-4C2F-97D0-DDDD1A6993BE}.Release|Any CPU.Build.0 = Release|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A32524C7-1B55-4108-A60E-0A60DE51638F}.Release|Any CPU.Build.0 = Release|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0ED01072-3C08-4934-BC3D-904C0D47B6AB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B38B8038-EBD1-4326-A01D-5AA1B7275A99} = {1D100B7C-E8B4-4909-878B-9F1EA4D8225D} + EndGlobalSection +EndGlobal diff --git a/LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj b/LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj new file mode 100644 index 00000000..331ff4fe --- /dev/null +++ b/LiveSPICE.PluginCore/LiveSPICE.PluginCore.csproj @@ -0,0 +1,13 @@ + + + net8.0 + enable + enable + LiveSPICE.PluginCore + + + + + + + diff --git a/LiveSPICE.PluginCore/PluginProgramParameters.cs b/LiveSPICE.PluginCore/PluginProgramParameters.cs new file mode 100644 index 00000000..954ac268 --- /dev/null +++ b/LiveSPICE.PluginCore/PluginProgramParameters.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; + +namespace LiveSPICE.PluginCore; + +public class PluginProgramParameters +{ + public string? SchematicPath { get; set; } + + public int OverSample { get; set; } = 2; + + public int Iterations { get; set; } = 8; + + public List ControlParameters { get; set; } = new List(); + + public static PluginProgramParameters FromProcessor(SimulationProcessor processor) + { + PluginProgramParameters parameters = new PluginProgramParameters + { + SchematicPath = processor.SchematicPath, + OverSample = processor.Oversample, + Iterations = processor.Iterations, + }; + + foreach (IComponentWrapper wrapper in processor.InteractiveComponents) + { + switch (wrapper) + { + case PotWrapper potWrapper: + parameters.ControlParameters.Add(new PluginProgramControlParameter { Name = wrapper.Name, Value = potWrapper.PotValue }); + break; + case DoubleThrowWrapper doubleThrowWrapper: + parameters.ControlParameters.Add(new PluginProgramControlParameter { Name = wrapper.Name, Value = doubleThrowWrapper.Engaged ? 1 : 0 }); + break; + case MultiThrowWrapper multiThrowWrapper: + parameters.ControlParameters.Add(new PluginProgramControlParameter { Name = wrapper.Name, Value = multiThrowWrapper.Position }); + break; + } + } + + return parameters; + } + + public void ApplyTo(SimulationProcessor processor) + { + processor.Oversample = OverSample; + processor.Iterations = Iterations; + + foreach (PluginProgramControlParameter controlParameter in ControlParameters) + { + IComponentWrapper? wrapper = processor.InteractiveComponents.SingleOrDefault(i => i.Name == controlParameter.Name); + if (wrapper == null) + continue; + + switch (wrapper) + { + case PotWrapper potWrapper: + potWrapper.PotValue = controlParameter.Value; + break; + case DoubleThrowWrapper doubleThrowWrapper: + doubleThrowWrapper.Engaged = controlParameter.Value == 1; + break; + case MultiThrowWrapper multiThrowWrapper: + multiThrowWrapper.Position = (int)controlParameter.Value; + break; + } + } + } +} + +public class PluginProgramControlParameter +{ + public string? Name { get; set; } + + public double Value { get; set; } +} diff --git a/LiveSPICE.PluginCore/SimulationProcessor.cs b/LiveSPICE.PluginCore/SimulationProcessor.cs new file mode 100644 index 00000000..b4d1c165 --- /dev/null +++ b/LiveSPICE.PluginCore/SimulationProcessor.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using Circuit; +using Util; +using ButtonWrapper = LiveSPICE.PluginCore.ComponentWrapper; + +namespace LiveSPICE.PluginCore; + +public class SimulationProcessor +{ + private double sampleRate; + private int oversample = 2; + private int iterations = 8; + private Circuit.Circuit? circuit; + private Simulation? simulation; + private bool needUpdate; + private bool needRebuild; + private int updateSamplesElapsed; + private int delayUpdateSamples; + private Exception? simulationUpdateException; + private int clock = -1; + private int update; + private readonly TaskScheduler scheduler = new RedundantTaskScheduler(1); + private readonly object sync = new object(); + + public SimulationProcessor() + { + InteractiveComponents = new ObservableCollection(); + SampleRate = 44100; + } + + public ObservableCollection InteractiveComponents { get; } + + public Schematic? Schematic { get; private set; } + + public string SchematicPath { get; private set; } = string.Empty; + + public string SchematicName => System.IO.Path.GetFileNameWithoutExtension(SchematicPath); + + public double SampleRate + { + get => sampleRate; + set + { + if (sampleRate == value) + return; + + sampleRate = value; + needRebuild = true; + delayUpdateSamples = (int)(sampleRate * .1); + } + } + + public int Oversample + { + get => oversample; + set + { + if (oversample == value) + return; + + oversample = value; + needRebuild = true; + } + } + + public int Iterations + { + get => iterations; + set + { + if (iterations == value) + return; + + iterations = value; + needRebuild = true; + } + } + + public void LoadSchematic(string path) + { + Schematic newSchematic = Circuit.Schematic.Load(path); + Circuit.Circuit newCircuit = newSchematic.Build(); + SetCircuit(newCircuit); + Schematic = newSchematic; + SchematicPath = path; + } + + public void ClearSchematic() + { + Schematic = null; + SchematicPath = string.Empty; + circuit = null; + InteractiveComponents.Clear(); + } + + public void RunSimulation(double[][] audioInputs, double[][] audioOutputs, int numSamples) + { + if (simulationUpdateException != null) + { + Exception toThrow = simulationUpdateException; + simulationUpdateException = null; + throw toThrow; + } + + if (simulation == null && needRebuild && circuit != null) + { + UpdateSimulation(needRebuild); + needRebuild = false; + } + + if (circuit == null || simulation == null) + { + audioInputs[0].CopyTo(audioOutputs[0], 0); + return; + } + + lock (sync) + { + foreach (IComponentWrapper component in InteractiveComponents) + { + if (component.NeedUpdate) + { + needUpdate = true; + component.NeedUpdate = false; + updateSamplesElapsed = 0; + } + + if (component.NeedRebuild) + { + needRebuild = true; + component.NeedRebuild = false; + } + } + + if (needUpdate || needRebuild) + { + if (needRebuild || updateSamplesElapsed > delayUpdateSamples) + { + UpdateSimulation(needRebuild); + needRebuild = false; + needUpdate = false; + } + else + { + updateSamplesElapsed += numSamples; + } + } + + simulation.Run(numSamples, audioInputs, audioOutputs); + } + } + + private void SetCircuit(Circuit.Circuit newCircuit) + { + circuit = newCircuit; + InteractiveComponents.Clear(); + + Dictionary buttonGroups = new Dictionary(); + Dictionary potGroups = new Dictionary(); + + foreach (Circuit.Component component in circuit.Components) + { + if (component is IPotControl pot) + AddPotControl(component, pot, potGroups); + else if (component is IButtonControl button) + AddButtonControl(component, button, buttonGroups); + } + + needRebuild = true; + } + + private void AddPotControl(Circuit.Component component, IPotControl pot, Dictionary potGroups) + { + if (string.IsNullOrEmpty(pot.Group)) + { + InteractiveComponents.Add(new PotWrapper(pot, component.Name)); + } + else if (potGroups.TryGetValue(pot.Group, out PotWrapper? wrapper)) + { + wrapper.AddSection(pot); + } + else + { + wrapper = new PotWrapper(pot, pot.Group); + potGroups.Add(pot.Group, wrapper); + InteractiveComponents.Add(wrapper); + } + } + + private void AddButtonControl(Circuit.Component component, IButtonControl button, Dictionary buttonGroups) + { + ButtonWrapper wrapper; + if (string.IsNullOrEmpty(button.Group)) + { + wrapper = button.NumPositions == 2 + ? new DoubleThrowWrapper(button, component.Name) + : new MultiThrowWrapper(button, component.Name); + InteractiveComponents.Add(wrapper); + } + else if (buttonGroups.ContainsKey(button.Group)) + { + wrapper = buttonGroups[button.Group]; + wrapper.AddSection(button); + } + else + { + wrapper = button.NumPositions == 2 + ? new DoubleThrowWrapper(button, button.Group) + : new MultiThrowWrapper(button, button.Group); + buttonGroups[button.Group] = wrapper; + InteractiveComponents.Add(wrapper); + } + } + + private void UpdateSimulation(bool rebuild) + { + int id = Interlocked.Increment(ref update); + new Task(() => + { + try + { + 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; + } + } + catch (Exception ex) + { + simulationUpdateException = ex; + } + }).Start(scheduler); + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs b/LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs new file mode 100644 index 00000000..751058ff --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/ComponentWrapper.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace LiveSPICE.PluginCore; + +public abstract class ComponentWrapper : IComponentWrapper +{ + protected ComponentWrapper(T component, string name) + { + Name = name; + AddSection(component); + } + + public string Name { get; } + + public bool NeedUpdate { get; set; } + + public bool NeedRebuild { get; set; } + + protected List Sections { get; } = new List(); + + public void AddSection(T section) + { + Sections.Add(section); + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs b/LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs new file mode 100644 index 00000000..8cee9b88 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/DoubleThrowWrapper.cs @@ -0,0 +1,28 @@ +using Circuit; + +namespace LiveSPICE.PluginCore; + +public class DoubleThrowWrapper : ComponentWrapper +{ + private bool engaged; + + public DoubleThrowWrapper(IButtonControl button, string name) : base(button, name) + { + } + + public bool Engaged + { + get => engaged; + set + { + if (value == engaged) + return; + + engaged = value; + foreach (IButtonControl button in Sections) + button.Click(); + + NeedRebuild = true; + } + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs b/LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs new file mode 100644 index 00000000..7f19dda4 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/IComponentWrapper.cs @@ -0,0 +1,10 @@ +namespace LiveSPICE.PluginCore; + +public interface IComponentWrapper +{ + string Name { get; } + + bool NeedRebuild { get; set; } + + bool NeedUpdate { get; set; } +} diff --git a/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs b/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs new file mode 100644 index 00000000..14a33194 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/MultiThrowWrapper.cs @@ -0,0 +1,27 @@ +using Circuit; + +namespace LiveSPICE.PluginCore; + +public class MultiThrowWrapper : ComponentWrapper +{ + public MultiThrowWrapper(IButtonControl button, string name) : base(button, name) + { + } + + public int NumPositions => Sections.Max(i => i.NumPositions); + + public int Position + { + get => Sections[0].Position; + set + { + if (value == Sections[0].Position) + return; + + foreach (IButtonControl section in Sections) + section.Position = value; + + NeedRebuild = true; + } + } +} diff --git a/LiveSPICE.PluginCore/Wrappers/PotWrapper.cs b/LiveSPICE.PluginCore/Wrappers/PotWrapper.cs new file mode 100644 index 00000000..0a2766f3 --- /dev/null +++ b/LiveSPICE.PluginCore/Wrappers/PotWrapper.cs @@ -0,0 +1,25 @@ +using Circuit; + +namespace LiveSPICE.PluginCore; + +public class PotWrapper : ComponentWrapper +{ + public PotWrapper(IPotControl pot, string name) : base(pot, name) + { + } + + public double PotValue + { + get => Sections[0].PotValue; + set + { + if (Sections[0].PotValue == value) + return; + + foreach (IPotControl section in Sections) + section.PotValue = value; + + NeedUpdate = true; + } + } +} diff --git a/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj new file mode 100644 index 00000000..3aa94cee --- /dev/null +++ b/LiveSPICE.PluginLinux/LiveSPICE.PluginLinux.csproj @@ -0,0 +1,20 @@ + + + net8.0 + Library + LiveSPICE.PluginLinux + LiveSPICE Linux VST + enable + enable + + + + + + + + + + + + diff --git a/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs new file mode 100644 index 00000000..523b4762 --- /dev/null +++ b/LiveSPICE.PluginLinux/LiveSPICELinuxPlugin.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Xml.Serialization; +using AudioPlugSharp; +using LiveSPICE.Avalonia; +using LiveSPICE.PluginCore; + +namespace LiveSPICE.PluginLinux; + +public class LiveSPICELinuxPlugin : AudioPluginBase +{ + private AudioIOPortManaged? monoInput; + private AudioIOPortManaged? monoOutput; + private bool haveSimulationError; + + public LiveSPICELinuxPlugin() + { + Company = string.Empty; + Website = "livespice.org"; + Contact = string.Empty; + PluginName = "LiveSPICE Linux"; + PluginCategory = "Fx"; + PluginVersion = "1.1.0"; + PluginID = 0xDC8558DC41A44872; + HasUserInterface = true; + EditorWidth = 700; + EditorHeight = 420; + SimulationProcessor = new SimulationProcessor(); + LoadDefaultSchematic(); + } + + public SimulationProcessor SimulationProcessor { get; } + + public string SchematicPath => SimulationProcessor.SchematicPath; + + public PluginEditorWindow CreateEditorWindow() + { + return new PluginEditorWindow(SimulationProcessor); + } + + public override void Initialize() + { + base.Initialize(); + InputPorts = new AudioIOPort[] { monoInput = new AudioIOPortManaged("Mono Input", EAudioChannelConfiguration.Mono) }; + OutputPorts = new AudioIOPort[] { monoOutput = new AudioIOPortManaged("Mono Output", EAudioChannelConfiguration.Mono) }; + } + + public override void InitializeProcessing() + { + base.InitializeProcessing(); + SimulationProcessor.SampleRate = Host.SampleRate; + } + + public void LoadSchematic(string path) + { + haveSimulationError = false; + SimulationProcessor.LoadSchematic(path); + } + + private void LoadDefaultSchematic() + { + Assembly assembly = typeof(LiveSPICELinuxPlugin).Assembly; + string? resourceName = assembly.GetManifestResourceNames().SingleOrDefault(i => i == "LiveSPICE.PluginLinux.DefaultSchematic.schx"); + if (resourceName == null) + return; + + string defaultSchematicPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LiveSPICE", + "PluginLinux", + "DefaultSchematic.schx"); + Directory.CreateDirectory(Path.GetDirectoryName(defaultSchematicPath)!); + + using Stream resource = assembly.GetManifestResourceStream(resourceName)!; + using FileStream file = File.Create(defaultSchematicPath); + resource.CopyTo(file); + file.Close(); + + LoadSchematic(defaultSchematicPath); + } + + public override byte[] SaveState() + { + PluginProgramParameters parameters = PluginProgramParameters.FromProcessor(SimulationProcessor); + XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); + using MemoryStream memoryStream = new MemoryStream(); + serializer.Serialize(memoryStream, parameters); + return memoryStream.ToArray(); + } + + public override void RestoreState(byte[] stateData) + { + XmlSerializer serializer = new XmlSerializer(typeof(PluginProgramParameters)); + try + { + using MemoryStream memoryStream = new MemoryStream(stateData); + if (serializer.Deserialize(memoryStream) is not PluginProgramParameters parameters) + return; + + if (string.IsNullOrEmpty(parameters.SchematicPath)) + { + haveSimulationError = false; + SimulationProcessor.ClearSchematic(); + } + else + { + LoadSchematic(parameters.SchematicPath); + } + + parameters.ApplyTo(SimulationProcessor); + } + catch (Exception ex) + { + Logger.Log("Load state failed: " + ex.Message); + } + } + + public override void Process() + { + base.Process(); + + if (monoInput == null || monoOutput == null) + return; + + if (haveSimulationError) + { + monoInput.PassThroughTo(monoOutput); + return; + } + + double[][] inputBuffers = monoInput.GetAudioBuffers(); + double[][] outputBuffers = monoOutput.GetAudioBuffers(); + + try + { + SimulationProcessor.RunSimulation(inputBuffers, outputBuffers, inputBuffers[0].Length); + } + catch (Exception ex) + { + haveSimulationError = true; + Logger.Log("Error running circuit simulation: " + ex.Message); + monoInput.PassThroughTo(monoOutput); + } + } +} diff --git a/LiveSPICE.sln b/LiveSPICE.sln index 6bf97481..34c80cde 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,10 @@ 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 + {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/Native/LiveSPICE.LV2/.gitignore b/Native/LiveSPICE.LV2/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/Native/LiveSPICE.LV2/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Native/LiveSPICE.LV2/Makefile b/Native/LiveSPICE.LV2/Makefile new file mode 100644 index 00000000..f4e74a00 --- /dev/null +++ b/Native/LiveSPICE.LV2/Makefile @@ -0,0 +1,62 @@ +CC ?= gcc +CFLAGS ?= -O3 -fPIC -Wall -Wextra -Werror +LDFLAGS ?= -shared +GTK3_CFLAGS := $(shell pkg-config --cflags gtk+-3.0) +GTK3_LIBS := $(shell pkg-config --libs gtk+-3.0) +BUNDLE := build/LiveSPICE.lv2 +MXR_PLUGIN := $(BUNDLE)/livespice_mxr_phase90.so +GENERIC_PLUGIN := $(BUNDLE)/livespice_generic.so +GENERIC_UI := $(BUNDLE)/livespice_generic_ui.so +UI_SMOKE := build/livespice_generic_ui_smoke +TTL := $(BUNDLE)/manifest.ttl $(BUNDLE)/livespice_mxr_phase90.ttl $(BUNDLE)/livespice_generic.ttl +ASSETS := $(BUNDLE)/MetalAlpha.png +PREFIX ?= $(HOME)/.lv2 +UI_SMOKE_SCHEMATIC ?= ../../Tests/Examples/MXR\ Phase\ 90.schx +UI_SMOKE_IMAGE ?= ../../out/lv2-generic-ui-smoke.png + +.PHONY: all install clean test ui-smoke + +all: $(MXR_PLUGIN) $(GENERIC_PLUGIN) $(GENERIC_UI) $(TTL) $(ASSETS) + +$(BUNDLE): + mkdir -p $(BUNDLE) + +$(MXR_PLUGIN): livespice_mxr_phase90.c | $(BUNDLE) + $(CC) $(CFLAGS) $< $(LDFLAGS) -lm -o $@ + +$(GENERIC_PLUGIN): livespice_generic.c | $(BUNDLE) + $(CC) $(CFLAGS) $< $(LDFLAGS) -lm -o $@ + +$(GENERIC_UI): livespice_generic_ui.c | $(BUNDLE) + $(CC) $(CFLAGS) $(GTK3_CFLAGS) $< $(LDFLAGS) $(GTK3_LIBS) -o $@ + +$(UI_SMOKE): livespice_generic_ui.c $(ASSETS) | $(BUNDLE) + $(CC) $(CFLAGS) $(GTK3_CFLAGS) -DLIVESPICE_UI_SMOKE $< $(GTK3_LIBS) -lm -o $@ + +$(BUNDLE)/manifest.ttl: manifest.ttl | $(BUNDLE) + cp $< $@ + +$(BUNDLE)/livespice_mxr_phase90.ttl: livespice_mxr_phase90.ttl | $(BUNDLE) + cp $< $@ + +$(BUNDLE)/livespice_generic.ttl: livespice_generic.ttl | $(BUNDLE) + cp $< $@ + +$(BUNDLE)/MetalAlpha.png: ../../LiveSPICEVst/Images/MetalAlpha.png | $(BUNDLE) + cp $< $@ + +install: all + mkdir -p $(PREFIX) + rm -rf $(PREFIX)/LiveSPICE.lv2 + cp -r $(BUNDLE) $(PREFIX)/ + +test: all + LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/mxr-phase90 + LV2_PATH=$(CURDIR)/build lv2ls | grep -F https://livespice.org/plugins/generic + +ui-smoke: $(UI_SMOKE) + mkdir -p ../../out + $(UI_SMOKE) $(CURDIR)/$(BUNDLE) $(UI_SMOKE_SCHEMATIC) $(UI_SMOKE_IMAGE) + +clean: + rm -rf build diff --git a/Native/LiveSPICE.LV2/README.md b/Native/LiveSPICE.LV2/README.md new file mode 100644 index 00000000..1d2a2513 --- /dev/null +++ b/Native/LiveSPICE.LV2/README.md @@ -0,0 +1,55 @@ +# LiveSPICE Native LV2 + +This is the Linux-native plugin target for hosts such as Carla, Ardour, and REAPER that support LV2. + +The bundle currently contains two native LV2 plugins: + +- `LiveSPICE Generic`: a generic LiveSPICE LV2 shell with mono input/output, `schematicPath` state support, and a GTK3 `Load Schematic` UI styled after the Windows VST metal panel. Loading a `.schx` updates the UI with discovered potentiometer and switch controls, and `Clear` resets the loaded schematic and control panel. It currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime. +- `LiveSPICE MXR Phase 90`: a mono MXR Phase 90-style phaser with `Speed` and `Trimmer` controls. + +Both build to real Linux ELF shared objects, unlike the AudioPlugSharp `.vst3` bridge that is Windows-only. + +It also exposes an LV2 state interface with a `https://livespice.org/ns/plugin#schematicPath` property. That is the Linux-native state hook needed for the generic LiveSPICE model where hosts save/restore the selected `.schx` path, matching the Windows plugin's program-state design. The current DSP is still the built-in phaser; wiring arbitrary `.schx` simulation into the native plugin is the next engine bridge step. + +## Build + +```bash +make -C Native/LiveSPICE.LV2 clean all +``` + +## Test Discovery + +```bash +make -C Native/LiveSPICE.LV2 test +make -C Native/LiveSPICE.LV2 ui-smoke +``` + +Expected URIs: + +```text +https://livespice.org/plugins/generic +https://livespice.org/plugins/mxr-phase90 +``` + +## Install + +```bash +make -C Native/LiveSPICE.LV2 install +``` + +This copies the bundle to: + +```text +~/.lv2/LiveSPICE.lv2 +``` + +## Test With Carla + +```bash +carla-single lv2 https://livespice.org/plugins/mxr-phase90 +carla-single lv2 https://livespice.org/plugins/generic +``` + +If the plugin loads successfully, Carla opens/runs the plugin host instead of immediately failing with a plugin description error. + +The `ui-smoke` target renders the generic GTK UI offscreen with the MXR Phase 90 example and writes `out/lv2-generic-ui-smoke.png` for layout review. diff --git a/Native/LiveSPICE.LV2/livespice_generic.c b/Native/LiveSPICE.LV2/livespice_generic.c new file mode 100644 index 00000000..90621c82 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_generic.c @@ -0,0 +1,198 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#define LIVESPICE_GENERIC_URI "https://livespice.org/plugins/generic" +#define LIVESPICE__schematicPath "https://livespice.org/ns/plugin#schematicPath" + +typedef enum { + PORT_INPUT = 0, + PORT_OUTPUT = 1, + PORT_CONTROL_EVENTS = 2 +} PortIndex; + +typedef struct { + const float* input; + float* output; + const LV2_Atom_Sequence* control_events; + char* schematic_path; + LV2_URID_Map* map; + LV2_URID atom_path; + LV2_URID atom_string; + LV2_URID schematic_path_key; +} LiveSpiceGeneric; + +static void map_features(LiveSpiceGeneric* self, const LV2_Feature* const* features) +{ + for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { + if (strcmp((*feature)->URI, LV2_URID__map) == 0) + self->map = (LV2_URID_Map*)(*feature)->data; + } + + if (self->map != NULL) { + self->atom_path = self->map->map(self->map->handle, LV2_ATOM__Path); + self->atom_string = self->map->map(self->map->handle, LV2_ATOM__String); + self->schematic_path_key = self->map->map(self->map->handle, LIVESPICE__schematicPath); + } +} + +static void set_schematic_path(LiveSpiceGeneric* self, const char* path, uint32_t size) +{ + if (path == NULL) + return; + + char* copy = (char*)calloc((size_t)size + 1, sizeof(char)); + if (copy == NULL) + return; + + if (size > 0) + memcpy(copy, path, size); + copy[size] = '\0'; + + free(self->schematic_path); + self->schematic_path = copy; +} + +static void read_control_events(LiveSpiceGeneric* self) +{ + if (self->control_events == NULL || self->control_events->atom.type == 0) + return; + + LV2_ATOM_SEQUENCE_FOREACH(self->control_events, event) { + if (event->body.type != self->atom_path && event->body.type != self->atom_string) + continue; + + const char* path = (const char*)LV2_ATOM_BODY(&event->body); + set_schematic_path(self, path, event->body.size); + } +} + +static LV2_Handle instantiate(const LV2_Descriptor* descriptor, double sample_rate, const char* bundle_path, const LV2_Feature* const* features) +{ + (void)descriptor; + (void)sample_rate; + (void)bundle_path; + + LiveSpiceGeneric* self = (LiveSpiceGeneric*)calloc(1, sizeof(LiveSpiceGeneric)); + if (self != NULL) + map_features(self, features); + return (LV2_Handle)self; +} + +static void connect_port(LV2_Handle instance, uint32_t port, void* data) +{ + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + switch ((PortIndex)port) { + case PORT_INPUT: + self->input = (const float*)data; + break; + case PORT_OUTPUT: + self->output = (float*)data; + break; + case PORT_CONTROL_EVENTS: + self->control_events = (const LV2_Atom_Sequence*)data; + break; + } +} + +static void activate(LV2_Handle instance) +{ + (void)instance; +} + +static void run(LV2_Handle instance, uint32_t sample_count) +{ + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + read_control_events(self); + + if (self->input == NULL || self->output == NULL) + return; + + memcpy(self->output, self->input, sample_count * sizeof(float)); +} + +static void deactivate(LV2_Handle instance) +{ + (void)instance; +} + +static void cleanup(LV2_Handle instance) +{ + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + free(self->schematic_path); + free(instance); +} + +static LV2_State_Status save_state(LV2_Handle instance, LV2_State_Store_Function store, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + if (self->schematic_path_key == 0 || self->atom_path == 0 || self->schematic_path == NULL || self->schematic_path[0] == '\0') + return LV2_STATE_SUCCESS; + + return store( + handle, + self->schematic_path_key, + self->schematic_path, + strlen(self->schematic_path) + 1, + self->atom_path, + LV2_STATE_IS_POD); +} + +static LV2_State_Status restore_state(LV2_Handle instance, LV2_State_Retrieve_Function retrieve, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceGeneric* self = (LiveSpiceGeneric*)instance; + if (self->schematic_path_key == 0) + return LV2_STATE_ERR_NO_FEATURE; + + size_t size = 0; + uint32_t type = 0; + uint32_t value_flags = 0; + const void* value = retrieve(handle, self->schematic_path_key, &size, &type, &value_flags); + if (value == NULL || size == 0) + return LV2_STATE_SUCCESS; + if (type != self->atom_path) + return LV2_STATE_ERR_BAD_TYPE; + + set_schematic_path(self, (const char*)value, (uint32_t)size); + return LV2_STATE_SUCCESS; +} + +static const LV2_State_Interface state_interface = { + save_state, + restore_state +}; + +static const void* extension_data(const char* uri) +{ + if (strcmp(uri, LV2_STATE__interface) == 0) + return &state_interface; + return NULL; +} + +static const LV2_Descriptor descriptor = { + LIVESPICE_GENERIC_URI, + instantiate, + connect_port, + activate, + run, + deactivate, + cleanup, + extension_data +}; + +LV2_SYMBOL_EXPORT const LV2_Descriptor* lv2_descriptor(uint32_t index) +{ + return index == 0 ? &descriptor : NULL; +} diff --git a/Native/LiveSPICE.LV2/livespice_generic.ttl b/Native/LiveSPICE.LV2/livespice_generic.ttl new file mode 100644 index 00000000..c03b7f46 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_generic.ttl @@ -0,0 +1,43 @@ +@prefix doap: . +@prefix foaf: . +@prefix atom: . +@prefix lv2: . +@prefix rdfs: . +@prefix state: . +@prefix ui: . +@prefix urid: . +@prefix livespice: . + + + a lv2:Plugin , lv2:SimulatorPlugin ; + doap:name "LiveSPICE Generic" ; + doap:license ; + doap:maintainer [ foaf:name "LiveSPICE" ] ; + lv2:extensionData state:interface ; + lv2:optionalFeature lv2:hardRTCapable ; + lv2:requiredFeature urid:map ; + ui:ui ; + livespice:schematicPath "" ; + rdfs:comment "Generic LiveSPICE LV2 shell. It provides a GTK3 Load Schematic UI, saves/restores a .schx schematic path, and currently passes audio through until the managed LiveSPICE simulation engine is bridged into the native LV2 runtime." ; + lv2:port [ + a lv2:InputPort , lv2:AudioPort ; + lv2:index 0 ; + lv2:symbol "input" ; + lv2:name "Input" + ] , [ + a lv2:OutputPort , lv2:AudioPort ; + lv2:index 1 ; + lv2:symbol "output" ; + lv2:name "Output" + ] , [ + a lv2:InputPort , atom:AtomPort ; + atom:bufferType atom:Sequence ; + atom:supports atom:Path ; + lv2:index 2 ; + lv2:symbol "control_events" ; + lv2:name "Control Events" + ] . + + + a ui:Gtk3UI ; + ui:binary . diff --git a/Native/LiveSPICE.LV2/livespice_generic_ui.c b/Native/LiveSPICE.LV2/livespice_generic_ui.c new file mode 100644 index 00000000..b70de029 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_generic_ui.c @@ -0,0 +1,852 @@ +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#define LIVESPICE_GENERIC_URI "https://livespice.org/plugins/generic" +#define LIVESPICE_GENERIC_UI_URI "https://livespice.org/plugins/generic#gtk3-ui" + +typedef enum { + PORT_INPUT = 0, + PORT_OUTPUT = 1, + PORT_CONTROL_EVENTS = 2 +} PortIndex; + +typedef struct { + char* name; + char* group; + char* type; + double value; + int positions; +} SchematicControl; + +typedef enum { + KNOB_SCALE_UNIPOLAR, + KNOB_SCALE_BIPOLAR +} KnobScale; + +typedef struct { + GtkWidget* area; + double value; + bool dragging; + double drag_y; + double drag_value; + KnobScale scale; +} KnobControl; + +typedef struct { + const char* keyword; + KnobScale scale; +} KnobNameRule; + +static const KnobNameRule knob_name_rules[] = { + { "treble", KNOB_SCALE_BIPOLAR }, + { "middle", KNOB_SCALE_BIPOLAR }, + { "mid", KNOB_SCALE_BIPOLAR }, + { "bass", KNOB_SCALE_BIPOLAR }, + { "tone", KNOB_SCALE_BIPOLAR }, + { "eq", KNOB_SCALE_BIPOLAR }, + { "equal", KNOB_SCALE_BIPOLAR }, + { "contour", KNOB_SCALE_BIPOLAR }, + { "tilt", KNOB_SCALE_BIPOLAR }, + { "cut", KNOB_SCALE_BIPOLAR }, + { "boost", KNOB_SCALE_BIPOLAR }, + { "gain", KNOB_SCALE_UNIPOLAR }, + { "drive", KNOB_SCALE_UNIPOLAR }, + { "dist", KNOB_SCALE_UNIPOLAR }, + { "fuzz", KNOB_SCALE_UNIPOLAR }, + { "volume", KNOB_SCALE_UNIPOLAR }, + { "vol", KNOB_SCALE_UNIPOLAR }, + { "master", KNOB_SCALE_UNIPOLAR }, + { "level", KNOB_SCALE_UNIPOLAR }, + { "output", KNOB_SCALE_UNIPOLAR }, + { "input", KNOB_SCALE_UNIPOLAR }, + { "trim", KNOB_SCALE_UNIPOLAR }, + { "trimmer", KNOB_SCALE_UNIPOLAR }, + { "presence", KNOB_SCALE_UNIPOLAR }, + { "presense", KNOB_SCALE_UNIPOLAR }, + { "resonance", KNOB_SCALE_UNIPOLAR }, + { "res", KNOB_SCALE_UNIPOLAR }, + { "speed", KNOB_SCALE_UNIPOLAR }, + { "rate", KNOB_SCALE_UNIPOLAR }, + { "depth", KNOB_SCALE_UNIPOLAR }, + { "mix", KNOB_SCALE_UNIPOLAR }, + { "blend", KNOB_SCALE_UNIPOLAR }, + { "feedback", KNOB_SCALE_UNIPOLAR }, + { "sustain", KNOB_SCALE_UNIPOLAR }, + { "attack", KNOB_SCALE_UNIPOLAR }, + { "release", KNOB_SCALE_UNIPOLAR }, +}; + +typedef struct { + LV2UI_Write_Function write; + LV2UI_Controller controller; + LV2UI_Resize* resize; + LV2_URID_Map* map; + LV2_URID atom_event_transfer; + LV2_Atom_Forge forge; + GtkWidget* root; + GtkWidget* path_label; + GtkWidget* controls_box; + GtkWidget* empty_controls_label; + char* schematic_path; + int control_count; +} LiveSpiceGenericUi; + +static const char* css_template = + "#livespice-root {" + " background-color: #666;" + " background-image: linear-gradient(145deg, rgba(255,255,255,0.55), rgba(255,255,255,0.08) 28%, rgba(0,0,0,0.12) 55%, rgba(255,255,255,0.20)), url('%s');" + " background-repeat: repeat;" + " border-radius: 8px;" + " border: 1px solid rgba(255,255,255,0.45);" + " box-shadow: inset 0 1px rgba(255,255,255,0.85), inset 0 -1px rgba(0,0,0,0.35);" + "}" + "#livespice-title { color: #111; font-weight: 800; font-size: 15px; text-shadow: 0 1px rgba(255,255,255,0.45); }" + "#livespice-path { color: #202020; font-weight: 700; text-shadow: 0 1px rgba(255,255,255,0.35); }" + ".livespice-button {" + " color: #111; font-weight: 700; padding: 4px 12px; border-radius: 4px;" + " background-image: linear-gradient(#f4f4f4, #b8b8b8 48%, #8f8f8f 52%, #d5d5d5);" + " border: 1px solid #4b4b4b; box-shadow: inset 0 1px rgba(255,255,255,0.9), 0 1px rgba(0,0,0,0.25);" + "}" + ".livespice-control-card {" + " padding: 2px 4px; min-width: 82px; min-height: 86px;" + "}" + ".livespice-control-label { color: #101010; font-weight: 800; font-size: 11px; text-shadow: 0 1px rgba(255,255,255,0.45); }" + ".livespice-combo { color: #111; font-weight: 700; }"; + +static char* duplicate_range(const char* start, size_t length) +{ + char* copy = (char*)calloc(length + 1, sizeof(char)); + if (copy == NULL) + return NULL; + + memcpy(copy, start, length); + return copy; +} + +static char* read_attribute(const char* element, const char* name) +{ + char pattern[64]; + snprintf(pattern, sizeof(pattern), "%s=\"", name); + + const char* value = strstr(element, pattern); + if (value == NULL) + return NULL; + + value += strlen(pattern); + const char* end = strchr(value, '"'); + if (end == NULL) + return NULL; + + return duplicate_range(value, (size_t)(end - value)); +} + +static bool component_is_type(const char* type, const char* component_name) +{ + return type != NULL && strstr(type, component_name) != NULL; +} + +static int switch_positions_from_type(const char* type) +{ + if (component_is_type(type, "SPDT")) + return 2; + if (component_is_type(type, "SP3T")) + return 3; + if (component_is_type(type, "SP4T")) + return 4; + if (component_is_type(type, "SP5T")) + return 5; + return 2; +} + +static char* control_display_name(const SchematicControl* control) +{ + if (control->group != NULL && control->group[0] != '\0') + return strdup(control->group); + if (control->name != NULL && control->name[0] != '\0') + return strdup(control->name); + return strdup(control->type != NULL ? control->type : "Control"); +} + +static void free_schematic_control(SchematicControl* control) +{ + free(control->name); + free(control->group); + free(control->type); +} + +static bool add_unique_control(GArray* controls, SchematicControl* control) +{ + char* display_name = control_display_name(control); + if (display_name == NULL) + return false; + + for (guint i = 0; i < controls->len; i++) { + SchematicControl* existing = &g_array_index(controls, SchematicControl, i); + char* existing_name = control_display_name(existing); + bool duplicate = existing_name != NULL && strcmp(existing_name, display_name) == 0 && strcmp(existing->type, control->type) == 0; + free(existing_name); + if (duplicate) { + free(display_name); + free_schematic_control(control); + return true; + } + } + + free(display_name); + g_array_append_val(controls, *control); + return true; +} + +static GArray* read_schematic_controls(const char* path) +{ + GArray* controls = g_array_new(false, false, sizeof(SchematicControl)); + if (path == NULL || path[0] == '\0') + return controls; + + gchar* contents = NULL; + gsize length = 0; + if (!g_file_get_contents(path, &contents, &length, NULL)) + return controls; + + const char* cursor = contents; + while ((cursor = strstr(cursor, "'); + if (end == NULL) + break; + + char* element = duplicate_range(cursor, (size_t)(end - cursor)); + if (element == NULL) + break; + + char* component_type = read_attribute(element, "_Type"); + if (component_is_type(component_type, "Potentiometer") || component_is_type(component_type, "VariableResistor")) { + SchematicControl control = { 0 }; + control.name = read_attribute(element, "Name"); + control.group = read_attribute(element, "Group"); + control.type = strdup("pot"); + char* wipe = read_attribute(element, "Wipe"); + control.value = wipe != NULL ? g_ascii_strtod(wipe, NULL) : 0.5; + control.positions = 0; + free(wipe); + add_unique_control(controls, &control); + } + else if (component_is_type(component_type, "SPDT") || component_is_type(component_type, "SP3T") || component_is_type(component_type, "SP4T") || component_is_type(component_type, "SP5T")) { + SchematicControl control = { 0 }; + control.name = read_attribute(element, "Name"); + control.group = read_attribute(element, "Group"); + control.type = strdup("switch"); + char* position = read_attribute(element, "Position"); + control.value = position != NULL ? g_ascii_strtod(position, NULL) : 0; + control.positions = switch_positions_from_type(component_type); + free(position); + add_unique_control(controls, &control); + } + + free(component_type); + free(element); + cursor = end + 1; + } + + g_free(contents); + return controls; +} + +static void gtk_init_once(void) +{ + static bool initialized = false; + if (!initialized) { + int argc = 0; + char** argv = NULL; + gtk_init(&argc, &argv); + initialized = true; + } +} + +static void add_css_class(GtkWidget* widget, const char* class_name) +{ + GtkStyleContext* context = gtk_widget_get_style_context(widget); + gtk_style_context_add_class(context, class_name); +} + +static void install_css(const char* bundle_path) +{ + char* texture_path = g_build_filename(bundle_path != NULL ? bundle_path : "", "MetalAlpha.png", NULL); + char* texture_uri = g_filename_to_uri(texture_path, NULL, NULL); + char* css = g_strdup_printf(css_template, texture_uri != NULL ? texture_uri : ""); + GtkCssProvider* provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(provider, css, -1, NULL); + gtk_style_context_add_provider_for_screen(gdk_screen_get_default(), GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(provider); + g_free(css); + g_free(texture_uri); + g_free(texture_path); +} + +static double clamp_unit(double value) +{ + if (value < 0) + return 0; + if (value > 1) + return 1; + return value; +} + +static double knob_angle_for_value(double value) +{ + return (135.0 + (270.0 * clamp_unit(value))) * G_PI / 180.0; +} + +static const char* knob_scale_label(KnobScale scale, int index) +{ + static const char* unipolar_labels[] = { "0", "2.5", "5", "7.5", "10" }; + static const char* bipolar_labels[] = { "-5", "-2.5", "0", "+2.5", "+5" }; + return scale == KNOB_SCALE_BIPOLAR ? bipolar_labels[index] : unipolar_labels[index]; +} + +static KnobScale classify_knob_scale(const char* name) +{ + if (name == NULL) + return KNOB_SCALE_UNIPOLAR; + + char* lower = g_ascii_strdown(name, -1); + KnobScale scale = KNOB_SCALE_UNIPOLAR; + for (size_t i = 0; i < sizeof(knob_name_rules) / sizeof(knob_name_rules[0]); i++) { + if (strstr(lower, knob_name_rules[i].keyword) != NULL) { + scale = knob_name_rules[i].scale; + break; + } + } + + g_free(lower); + return scale; +} + +static void set_label_path(LiveSpiceGenericUi* self, const char* path) +{ + gtk_label_set_text(GTK_LABEL(self->path_label), path != NULL && path[0] != '\0' ? path : "No schematic loaded"); +} + +static void clear_control_panel(LiveSpiceGenericUi* self) +{ + GList* children = gtk_container_get_children(GTK_CONTAINER(self->controls_box)); + for (GList* child = children; child != NULL; child = child->next) + gtk_widget_destroy(GTK_WIDGET(child->data)); + g_list_free(children); + self->control_count = 0; +} + +static void request_ui_size(LiveSpiceGenericUi* self) +{ + if (self->resize == NULL || self->resize->ui_resize == NULL) + return; + + const int base_width = 470; + const int control_width = 132; + const int max_width = 920; + int width = base_width + (self->control_count * control_width); + if (width < base_width) + width = base_width; + if (width > max_width) + width = max_width; + + int height = self->control_count > 0 ? 190 : 145; + self->resize->ui_resize(self->resize->handle, width, height); +} + +static GtkWidget* create_control_label(const char* text) +{ + GtkWidget* label = gtk_label_new(text); + add_css_class(label, "livespice-control-label"); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + return label; +} + +static gboolean draw_knob(GtkWidget* widget, cairo_t* cr, gpointer data) +{ + (void)widget; + KnobControl* knob = (KnobControl*)data; + GtkAllocation allocation; + gtk_widget_get_allocation(knob->area, &allocation); + + double size = allocation.width < allocation.height ? allocation.width : allocation.height; + double center_x = allocation.width / 2.0; + double center_y = allocation.height / 2.0; + double radius = (size / 2.0) - 16.0; + double value = clamp_unit(knob->value); + + const double label_values[] = { 0, 0.25, 0.5, 0.75, 1 }; + + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD); + for (int i = 0; i <= 16; i++) { + double tick_value = (double)i / 16.0; + double tick_angle = knob_angle_for_value(tick_value); + bool major = i % 4 == 0; + bool medium = i % 2 == 0; + double inner = radius + 1.0; + double outer = radius + (major ? 8.2 : (medium ? 5.8 : 4.0)); + cairo_set_source_rgba(cr, 0.02, 0.02, 0.02, 0.80); + cairo_set_line_width(cr, major ? 2.1 : (medium ? 1.45 : 1.0)); + cairo_move_to(cr, center_x + cos(tick_angle) * inner, center_y + sin(tick_angle) * inner); + cairo_line_to(cr, center_x + cos(tick_angle) * outer, center_y + sin(tick_angle) * outer); + cairo_stroke(cr); + + } + + cairo_pattern_t* shadow = cairo_pattern_create_radial(center_x + 3, center_y + 5, radius * 0.15, center_x + 3, center_y + 5, radius); + cairo_pattern_add_color_stop_rgba(shadow, 0, 0, 0, 0, 0.20); + cairo_pattern_add_color_stop_rgba(shadow, 1, 0, 0, 0, 0.00); + cairo_set_source(cr, shadow); + cairo_arc(cr, center_x + 3, center_y + 5, radius, 0, 2 * G_PI); + cairo_fill(cr); + cairo_pattern_destroy(shadow); + + cairo_pattern_t* body = cairo_pattern_create_radial(center_x - radius * 0.35, center_y - radius * 0.45, radius * 0.1, center_x, center_y, radius); + cairo_pattern_add_color_stop_rgb(body, 0, 0.96, 0.96, 0.93); + cairo_pattern_add_color_stop_rgb(body, 0.42, 0.58, 0.58, 0.56); + cairo_pattern_add_color_stop_rgb(body, 1, 0.14, 0.14, 0.14); + cairo_set_source(cr, body); + cairo_arc(cr, center_x, center_y, radius, 0, 2 * G_PI); + cairo_fill_preserve(cr); + cairo_pattern_destroy(body); + + cairo_set_source_rgb(cr, 0.07, 0.07, 0.07); + cairo_set_line_width(cr, 1.2); + cairo_stroke(cr); + + cairo_pattern_t* cap = cairo_pattern_create_linear(center_x, center_y - radius, center_x, center_y + radius); + cairo_pattern_add_color_stop_rgba(cap, 0, 1, 1, 1, 0.48); + cairo_pattern_add_color_stop_rgba(cap, 0.45, 1, 1, 1, 0.06); + cairo_pattern_add_color_stop_rgba(cap, 1, 0, 0, 0, 0.22); + cairo_set_source(cr, cap); + cairo_arc(cr, center_x, center_y, radius - 3, 0, 2 * G_PI); + cairo_fill(cr); + cairo_pattern_destroy(cap); + + double angle = knob_angle_for_value(value); + double indicator_inner = radius * 0.18; + double indicator_outer = radius * 0.78; + cairo_set_source_rgb(cr, 0.02, 0.02, 0.02); + cairo_set_line_width(cr, 4.0); + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_move_to(cr, center_x + cos(angle) * indicator_inner, center_y + sin(angle) * indicator_inner); + cairo_line_to(cr, center_x + cos(angle) * indicator_outer, center_y + sin(angle) * indicator_outer); + cairo_stroke(cr); + + cairo_set_font_size(cr, 11.5); + for (int i = 0; i < 5; i++) { + double tick_angle = knob_angle_for_value(label_values[i]); + const char* label = knob_scale_label(knob->scale, i); + cairo_text_extents_t extents; + cairo_text_extents(cr, label, &extents); + double label_radius = radius + 17.0; + double label_x = center_x + cos(tick_angle) * label_radius - (extents.width / 2.0) - extents.x_bearing; + double label_y = center_y + sin(tick_angle) * label_radius + (extents.height / 2.0); + if (i == 2) + label_y = 3.0 - extents.y_bearing; + + cairo_set_source_rgba(cr, 1, 1, 1, 0.78); + cairo_move_to(cr, label_x + 1, label_y + 1); + cairo_show_text(cr, label); + cairo_set_source_rgba(cr, 0, 0, 0, 0.82); + cairo_move_to(cr, label_x, label_y); + cairo_show_text(cr, label); + } + + cairo_set_source_rgba(cr, 1, 1, 1, 0.85); + cairo_set_line_width(cr, 1.2); + cairo_move_to(cr, center_x + cos(angle) * indicator_inner, center_y + sin(angle) * indicator_inner); + cairo_line_to(cr, center_x + cos(angle) * indicator_outer, center_y + sin(angle) * indicator_outer); + cairo_stroke(cr); + + return false; +} + +static gboolean knob_button_press(GtkWidget* widget, GdkEventButton* event, gpointer data) +{ + (void)widget; + KnobControl* knob = (KnobControl*)data; + if (event->button != GDK_BUTTON_PRIMARY) + return false; + + knob->dragging = true; + knob->drag_y = event->y_root; + knob->drag_value = knob->value; + return true; +} + +static gboolean knob_button_release(GtkWidget* widget, GdkEventButton* event, gpointer data) +{ + (void)widget; + (void)event; + ((KnobControl*)data)->dragging = false; + return true; +} + +static gboolean knob_motion(GtkWidget* widget, GdkEventMotion* event, gpointer data) +{ + KnobControl* knob = (KnobControl*)data; + if (!knob->dragging) + return false; + + knob->value = clamp_unit(knob->drag_value + ((knob->drag_y - event->y_root) / 120.0)); + gtk_widget_queue_draw(widget); + return true; +} + +static gboolean knob_scroll(GtkWidget* widget, GdkEventScroll* event, gpointer data) +{ + KnobControl* knob = (KnobControl*)data; + double delta = 0.0; + if (event->direction == GDK_SCROLL_UP) + delta = 0.025; + else if (event->direction == GDK_SCROLL_DOWN) + delta = -0.025; + else if (event->direction == GDK_SCROLL_SMOOTH) + delta = fmax(-0.1, fmin(0.1, -event->delta_y * 0.025)); + else + return false; + + knob->value = clamp_unit(knob->value + delta); + gtk_widget_queue_draw(widget); + return true; +} + +static void free_knob_control(gpointer data) +{ + free(data); +} + +static GtkWidget* create_knob(double value, const char* name) +{ + KnobControl* knob = (KnobControl*)calloc(1, sizeof(KnobControl)); + knob->value = clamp_unit(value); + knob->scale = classify_knob_scale(name); + knob->area = gtk_drawing_area_new(); + gtk_widget_set_size_request(knob->area, 124, 112); + gtk_widget_add_events(knob->area, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK); + g_signal_connect(knob->area, "draw", G_CALLBACK(draw_knob), knob); + g_signal_connect(knob->area, "button-press-event", G_CALLBACK(knob_button_press), knob); + g_signal_connect(knob->area, "button-release-event", G_CALLBACK(knob_button_release), knob); + g_signal_connect(knob->area, "motion-notify-event", G_CALLBACK(knob_motion), knob); + g_signal_connect(knob->area, "scroll-event", G_CALLBACK(knob_scroll), knob); + g_object_set_data_full(G_OBJECT(knob->area), "livespice-knob", knob, free_knob_control); + return knob->area; +} + +static GtkWidget* create_pot_control(const SchematicControl* control) +{ + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + add_css_class(box, "livespice-control-card"); + gtk_widget_set_size_request(box, 86, 90); + gtk_widget_set_valign(box, GTK_ALIGN_START); + char* name = control_display_name(control); + gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Pot"), false, false, 0); + + GtkWidget* knob = create_knob(control->value, name); + gtk_widget_set_halign(knob, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(box), knob, false, false, 0); + free(name); + return box; +} + +static GtkWidget* create_switch_control(const SchematicControl* control) +{ + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + add_css_class(box, "livespice-control-card"); + gtk_widget_set_size_request(box, 86, 90); + gtk_widget_set_valign(box, GTK_ALIGN_START); + char* name = control_display_name(control); + gtk_box_pack_start(GTK_BOX(box), create_control_label(name != NULL ? name : "Switch"), false, false, 0); + + GtkWidget* combo = gtk_combo_box_text_new(); + for (int i = 0; i < control->positions; i++) { + char text[16]; + snprintf(text, sizeof(text), "%d", i); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), text); + } + gtk_combo_box_set_active(GTK_COMBO_BOX(combo), (int)control->value); + add_css_class(combo, "livespice-combo"); + gtk_box_pack_start(GTK_BOX(box), combo, false, false, 0); + free(name); + return box; +} + +static void rebuild_control_panel(LiveSpiceGenericUi* self, const char* path) +{ + clear_control_panel(self); + + GArray* controls = read_schematic_controls(path); + if (controls->len == 0) { + self->empty_controls_label = create_control_label(path != NULL && path[0] != '\0' ? "No schematic controls found" : "Load a schematic to show controls"); + gtk_box_pack_start(GTK_BOX(self->controls_box), self->empty_controls_label, false, false, 0); + } + else { + for (guint i = 0; i < controls->len; i++) { + SchematicControl* control = &g_array_index(controls, SchematicControl, i); + GtkWidget* widget = strcmp(control->type, "switch") == 0 ? create_switch_control(control) : create_pot_control(control); + gtk_box_pack_start(GTK_BOX(self->controls_box), widget, false, false, 0); + } + } + self->control_count = (int)controls->len; + + for (guint i = 0; i < controls->len; i++) + free_schematic_control(&g_array_index(controls, SchematicControl, i)); + g_array_free(controls, true); + gtk_widget_show_all(self->controls_box); + request_ui_size(self); +} + +static void send_schematic_path(LiveSpiceGenericUi* self, const char* path) +{ + if (self->map == NULL || path == NULL) + return; + + uint8_t buffer[4096]; + lv2_atom_forge_set_buffer(&self->forge, buffer, sizeof(buffer)); + + LV2_Atom_Forge_Frame sequence_frame; + LV2_Atom_Forge_Ref sequence_ref = lv2_atom_forge_sequence_head(&self->forge, &sequence_frame, 0); + if (sequence_ref == 0) + return; + + lv2_atom_forge_frame_time(&self->forge, 0); + if (lv2_atom_forge_path(&self->forge, path, (uint32_t)strlen(path) + 1) == 0) + return; + + lv2_atom_forge_pop(&self->forge, &sequence_frame); + LV2_Atom* atom = lv2_atom_forge_deref(&self->forge, sequence_ref); + self->write(self->controller, PORT_CONTROL_EVENTS, lv2_atom_total_size(atom), self->atom_event_transfer, atom); +} + +static void set_schematic_path(LiveSpiceGenericUi* self, const char* path) +{ + char* copy = strdup(path); + if (copy == NULL) + return; + + free(self->schematic_path); + self->schematic_path = copy; + set_label_path(self, copy); + rebuild_control_panel(self, copy); + send_schematic_path(self, copy); +} + +static void load_schematic_clicked(GtkButton* button, gpointer data) +{ + (void)button; + LiveSpiceGenericUi* self = (LiveSpiceGenericUi*)data; + + GtkWidget* dialog = gtk_file_chooser_dialog_new( + "Load LiveSPICE Schematic", + NULL, + GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", + GTK_RESPONSE_CANCEL, + "_Open", + GTK_RESPONSE_ACCEPT, + NULL); + + GtkFileFilter* filter = gtk_file_filter_new(); + gtk_file_filter_set_name(filter, "LiveSPICE schematics"); + gtk_file_filter_add_pattern(filter, "*.schx"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + char* filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + if (filename != NULL) { + set_schematic_path(self, filename); + g_free(filename); + } + } + + gtk_widget_destroy(dialog); +} + +static void clear_schematic_clicked(GtkButton* button, gpointer data) +{ + (void)button; + set_schematic_path((LiveSpiceGenericUi*)data, ""); +} + +static LV2UI_Handle instantiate( + const LV2UI_Descriptor* descriptor, + const char* plugin_uri, + const char* bundle_path, + LV2UI_Write_Function write_function, + LV2UI_Controller controller, + LV2UI_Widget* widget, + const LV2_Feature* const* features) +{ + (void)descriptor; + if (strcmp(plugin_uri, LIVESPICE_GENERIC_URI) != 0) + return NULL; + + LiveSpiceGenericUi* self = (LiveSpiceGenericUi*)calloc(1, sizeof(LiveSpiceGenericUi)); + if (self == NULL) + return NULL; + + self->write = write_function; + self->controller = controller; + + for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { + if (strcmp((*feature)->URI, LV2_URID__map) == 0) + self->map = (LV2_URID_Map*)(*feature)->data; + else if (strcmp((*feature)->URI, LV2_UI__resize) == 0) + self->resize = (LV2UI_Resize*)(*feature)->data; + } + + if (self->map != NULL) { + self->atom_event_transfer = self->map->map(self->map->handle, LV2_ATOM__eventTransfer); + lv2_atom_forge_init(&self->forge, self->map); + } + + gtk_init_once(); + install_css(bundle_path); + + self->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(self->root, "livespice-root"); + gtk_container_set_border_width(GTK_CONTAINER(self->root), 10); + + GtkWidget* title = gtk_label_new("LiveSPICE Generic"); + gtk_widget_set_name(title, "livespice-title"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(self->root), title, false, false, 0); + + self->path_label = gtk_label_new("No schematic loaded"); + gtk_widget_set_name(self->path_label, "livespice-path"); + gtk_label_set_ellipsize(GTK_LABEL(self->path_label), PANGO_ELLIPSIZE_MIDDLE); + gtk_widget_set_halign(self->path_label, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(self->root), self->path_label, false, false, 0); + + GtkWidget* controls = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* load_button = gtk_button_new_with_label("Load Schematic"); + GtkWidget* clear_button = gtk_button_new_with_label("Clear"); + add_css_class(load_button, "livespice-button"); + add_css_class(clear_button, "livespice-button"); + gtk_box_pack_start(GTK_BOX(controls), load_button, false, false, 0); + gtk_box_pack_start(GTK_BOX(controls), clear_button, false, false, 0); + gtk_box_pack_start(GTK_BOX(self->root), controls, false, false, 0); + + GtkWidget* scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_NEVER); + gtk_widget_set_size_request(scroll, 320, 112); + gtk_widget_set_valign(scroll, GTK_ALIGN_START); + self->controls_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_container_add(GTK_CONTAINER(scroll), self->controls_box); + gtk_box_pack_start(GTK_BOX(self->root), scroll, false, false, 0); + rebuild_control_panel(self, ""); + + g_signal_connect(load_button, "clicked", G_CALLBACK(load_schematic_clicked), self); + g_signal_connect(clear_button, "clicked", G_CALLBACK(clear_schematic_clicked), self); + + gtk_widget_show_all(self->root); + *widget = self->root; + return (LV2UI_Handle)self; +} + +static void cleanup(LV2UI_Handle ui) +{ + LiveSpiceGenericUi* self = (LiveSpiceGenericUi*)ui; + free(self->schematic_path); + free(self); +} + +static void port_event(LV2UI_Handle ui, uint32_t port_index, uint32_t buffer_size, uint32_t format, const void* buffer) +{ + (void)ui; + (void)port_index; + (void)buffer_size; + (void)format; + (void)buffer; +} + +static const void* extension_data(const char* uri) +{ + (void)uri; + return NULL; +} + +static const LV2UI_Descriptor descriptor = { + LIVESPICE_GENERIC_UI_URI, + instantiate, + cleanup, + port_event, + extension_data +}; + +LV2_SYMBOL_EXPORT const LV2UI_Descriptor* lv2ui_descriptor(uint32_t index) +{ + return index == 0 ? &descriptor : NULL; +} + +#ifdef LIVESPICE_UI_SMOKE +static uint32_t smoke_map_uri(LV2_URID_Map_Handle handle, const char* uri) +{ + (void)handle; + if (strcmp(uri, LV2_ATOM__eventTransfer) == 0) + return 1; + if (strcmp(uri, LV2_ATOM__Path) == 0) + return 2; + if (strcmp(uri, LV2_ATOM__String) == 0) + return 3; + return 100; +} + +static void smoke_write(LV2UI_Controller controller, uint32_t port_index, uint32_t buffer_size, uint32_t port_protocol, const void* buffer) +{ + (void)controller; + (void)port_index; + (void)buffer_size; + (void)port_protocol; + (void)buffer; +} + +int main(int argc, char** argv) +{ + if (argc < 4) + return 2; + + gtk_init(&argc, &argv); + + LV2_URID_Map map = { NULL, smoke_map_uri }; + LV2_Feature map_feature = { LV2_URID__map, &map }; + const LV2_Feature* features[] = { &map_feature, NULL }; + LV2UI_Widget widget = NULL; + LiveSpiceGenericUi* ui = (LiveSpiceGenericUi*)instantiate(&descriptor, LIVESPICE_GENERIC_URI, argv[1], smoke_write, NULL, &widget, features); + if (ui == NULL || widget == NULL) + return 3; + + set_schematic_path(ui, argv[2]); + + GtkWidget* window = gtk_offscreen_window_new(); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(widget)); + gtk_widget_set_size_request(window, 760, 190); + gtk_widget_show_all(window); + + while (gtk_events_pending()) + gtk_main_iteration(); + + GdkPixbuf* pixbuf = gtk_offscreen_window_get_pixbuf(GTK_OFFSCREEN_WINDOW(window)); + if (pixbuf == NULL) + return 4; + + gboolean ok = gdk_pixbuf_save(pixbuf, argv[3], "png", NULL, NULL); + g_object_unref(pixbuf); + cleanup((LV2UI_Handle)ui); + gtk_widget_destroy(window); + return ok ? 0 : 5; +} +#endif \ No newline at end of file diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.c b/Native/LiveSPICE.LV2/livespice_mxr_phase90.c new file mode 100644 index 00000000..62a6fb7d --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.c @@ -0,0 +1,221 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +#define LIVESPICE_MXR_PHASE90_URI "https://livespice.org/plugins/mxr-phase90" +#define LIVESPICE__schematicPath "https://livespice.org/ns/plugin#schematicPath" + +#define MIN_RATE_HZ 0.05f +#define MAX_RATE_HZ 8.0f +#define MIN_SWEEP_HZ 180.0f +#define MAX_SWEEP_HZ 1800.0f +#define STAGE_COUNT 4 + +typedef enum { + PORT_SPEED = 0, + PORT_TRIMMER = 1, + PORT_INPUT = 2, + PORT_OUTPUT = 3 +} PortIndex; + +typedef struct { + const float* speed; + const float* trimmer; + const float* input; + float* output; + double sample_rate; + float phase; + float x1[STAGE_COUNT]; + float y1[STAGE_COUNT]; + char* schematic_path; + LV2_URID_Map* map; + LV2_URID atom_path; + LV2_URID schematic_path_key; +} LiveSpiceMxrPhase90; + +static float clampf(float value, float min, float max) +{ + if (value < min) + return min; + if (value > max) + return max; + return value; +} + +static LV2_Handle instantiate(const LV2_Descriptor* descriptor, double sample_rate, const char* bundle_path, const LV2_Feature* const* features) +{ + (void)descriptor; + (void)bundle_path; + + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)calloc(1, sizeof(LiveSpiceMxrPhase90)); + if (self != NULL) { + self->sample_rate = sample_rate; + for (const LV2_Feature* const* feature = features; feature != NULL && *feature != NULL; feature++) { + if (strcmp((*feature)->URI, LV2_URID__map) == 0) + self->map = (LV2_URID_Map*)(*feature)->data; + } + if (self->map != NULL) { + self->atom_path = self->map->map(self->map->handle, LV2_ATOM__Path); + self->schematic_path_key = self->map->map(self->map->handle, LIVESPICE__schematicPath); + } + } + return (LV2_Handle)self; +} + +static void connect_port(LV2_Handle instance, uint32_t port, void* data) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + switch ((PortIndex)port) { + case PORT_SPEED: + self->speed = (const float*)data; + break; + case PORT_TRIMMER: + self->trimmer = (const float*)data; + break; + case PORT_INPUT: + self->input = (const float*)data; + break; + case PORT_OUTPUT: + self->output = (float*)data; + break; + } +} + +static void activate(LV2_Handle instance) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + self->phase = 0.0f; + memset(self->x1, 0, sizeof(self->x1)); + memset(self->y1, 0, sizeof(self->y1)); +} + +static float process_allpass(LiveSpiceMxrPhase90* self, float input, float coefficient, uint32_t stage) +{ + float output = coefficient * input + self->x1[stage] - coefficient * self->y1[stage]; + self->x1[stage] = input; + self->y1[stage] = output; + return output; +} + +static void run(LV2_Handle instance, uint32_t sample_count) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + if (self->input == NULL || self->output == NULL) + return; + + float speed = self->speed != NULL ? clampf(*self->speed, 0.0f, 1.0f) : 0.5f; + float trimmer = self->trimmer != NULL ? clampf(*self->trimmer, 0.0f, 1.0f) : 0.5f; + float rate = MIN_RATE_HZ * powf(MAX_RATE_HZ / MIN_RATE_HZ, speed); + float depth = 0.25f + trimmer * 0.75f; + float mix = 0.35f + trimmer * 0.45f; + float phase_increment = rate / (float)self->sample_rate; + + for (uint32_t sample = 0; sample < sample_count; sample++) { + float lfo = 0.5f + 0.5f * sinf(2.0f * (float)M_PI * self->phase); + float sweep = MIN_SWEEP_HZ + (MAX_SWEEP_HZ - MIN_SWEEP_HZ) * lfo * depth; + float tangent = tanf((float)M_PI * sweep / (float)self->sample_rate); + float coefficient = (1.0f - tangent) / (1.0f + tangent); + + float wet = self->input[sample]; + for (uint32_t stage = 0; stage < STAGE_COUNT; stage++) + wet = process_allpass(self, wet, coefficient, stage); + + self->output[sample] = self->input[sample] * (1.0f - mix) + wet * mix; + + self->phase += phase_increment; + if (self->phase >= 1.0f) + self->phase -= floorf(self->phase); + } +} + +static void deactivate(LV2_Handle instance) +{ + (void)instance; +} + +static void cleanup(LV2_Handle instance) +{ + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + free(self->schematic_path); + free(instance); +} + +static LV2_State_Status save_state(LV2_Handle instance, LV2_State_Store_Function store, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + if (self->schematic_path_key == 0 || self->atom_path == 0 || self->schematic_path == NULL || self->schematic_path[0] == '\0') + return LV2_STATE_SUCCESS; + + return store( + handle, + self->schematic_path_key, + self->schematic_path, + strlen(self->schematic_path) + 1, + self->atom_path, + LV2_STATE_IS_POD); +} + +static LV2_State_Status restore_state(LV2_Handle instance, LV2_State_Retrieve_Function retrieve, LV2_State_Handle handle, uint32_t flags, const LV2_Feature* const* features) +{ + (void)flags; + (void)features; + + LiveSpiceMxrPhase90* self = (LiveSpiceMxrPhase90*)instance; + if (self->schematic_path_key == 0) + return LV2_STATE_ERR_NO_FEATURE; + + size_t size = 0; + uint32_t type = 0; + uint32_t value_flags = 0; + const void* value = retrieve(handle, self->schematic_path_key, &size, &type, &value_flags); + if (value == NULL || size == 0) + return LV2_STATE_SUCCESS; + if (type != self->atom_path) + return LV2_STATE_ERR_BAD_TYPE; + + char* restored = (char*)calloc(size + 1, sizeof(char)); + if (restored == NULL) + return LV2_STATE_ERR_NO_SPACE; + memcpy(restored, value, size); + + free(self->schematic_path); + self->schematic_path = restored; + return LV2_STATE_SUCCESS; +} + +static const LV2_State_Interface state_interface = { + save_state, + restore_state +}; + +static const void* extension_data(const char* uri) +{ + if (strcmp(uri, LV2_STATE__interface) == 0) + return &state_interface; + return NULL; +} + +static const LV2_Descriptor descriptor = { + LIVESPICE_MXR_PHASE90_URI, + instantiate, + connect_port, + activate, + run, + deactivate, + cleanup, + extension_data +}; + +LV2_SYMBOL_EXPORT const LV2_Descriptor* lv2_descriptor(uint32_t index) +{ + return index == 0 ? &descriptor : NULL; +} diff --git a/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl new file mode 100644 index 00000000..1d8c7ab0 --- /dev/null +++ b/Native/LiveSPICE.LV2/livespice_mxr_phase90.ttl @@ -0,0 +1,49 @@ +@prefix doap: . +@prefix foaf: . +@prefix lv2: . +@prefix pprops: . +@prefix rdfs: . +@prefix state: . +@prefix units: . +@prefix urid: . +@prefix livespice: . + + + a lv2:Plugin , lv2:PhaserPlugin ; + doap:name "LiveSPICE MXR Phase 90" ; + doap:license ; + doap:maintainer [ foaf:name "LiveSPICE" ] ; + lv2:extensionData state:interface ; + lv2:requiredFeature urid:map ; + lv2:optionalFeature lv2:hardRTCapable ; + livespice:schematicPath "" ; + lv2:port [ + a lv2:InputPort , lv2:ControlPort ; + lv2:index 0 ; + lv2:symbol "speed" ; + lv2:name "Speed" ; + lv2:default 0.5 ; + lv2:minimum 0.0 ; + lv2:maximum 1.0 ; + units:unit units:coef ; + pprops:logarithmic true + ] , [ + a lv2:InputPort , lv2:ControlPort ; + lv2:index 1 ; + lv2:symbol "trimmer" ; + lv2:name "Trimmer" ; + lv2:default 0.5 ; + lv2:minimum 0.0 ; + lv2:maximum 1.0 ; + units:unit units:coef + ] , [ + a lv2:InputPort , lv2:AudioPort ; + lv2:index 2 ; + lv2:symbol "input" ; + lv2:name "Input" + ] , [ + a lv2:OutputPort , lv2:AudioPort ; + lv2:index 3 ; + lv2:symbol "output" ; + lv2:name "Output" + ] . diff --git a/Native/LiveSPICE.LV2/manifest.ttl b/Native/LiveSPICE.LV2/manifest.ttl new file mode 100644 index 00000000..1c58975d --- /dev/null +++ b/Native/LiveSPICE.LV2/manifest.ttl @@ -0,0 +1,12 @@ +@prefix lv2: . +@prefix rdfs: . + + + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . + + + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . diff --git a/PR_DESCRIPTION_LINUX_GUI_PORT.md b/PR_DESCRIPTION_LINUX_GUI_PORT.md new file mode 100644 index 00000000..a311d973 --- /dev/null +++ b/PR_DESCRIPTION_LINUX_GUI_PORT.md @@ -0,0 +1,47 @@ +# Title + +Add Linux GUI, audio, and LV2 plugin port + +# Description + +## Summary + +Adds a Linux-focused LiveSPICE port alongside the existing Windows application instead of replacing it. + +This branch introduces an Avalonia desktop editor, Linux audio configuration and live simulation support, a shared plugin core for Linux plugin experiments, and native LV2 plugin bundles with a GTK3 UI. The main Windows solution and Windows WPF/VST UI paths are intentionally left unchanged; Linux-specific projects live in `LiveSPICE.Linux.sln`. + +## Highlights + +- Adds `LiveSPICE.Avalonia` as a Linux desktop editor with schematic loading, editing, property inspection, waveform viewing, and live audio controls. +- Adds Linux audio backends and configuration flow, including JACK-oriented device naming and a virtual audio mode for validation. +- Adds `LiveSPICE.Avalonia.Tests` coverage for editor interaction, launch/open behavior, settings, audio simulation flow, plugin port behavior, and GUI parity checks. +- Adds `LiveSPICE.PluginCore` and `LiveSPICE.PluginLinux` for Linux plugin-facing simulation/control logic without changing the Windows VST UI. +- Adds native LV2 plugin bundles under `Native/LiveSPICE.LV2`, including an MXR Phase 90 plugin and a generic schematic-loader plugin shell. +- Adds a GTK3 LV2 UI with schematic selection, discovered controls, Windows-style plugin presentation, knob scales/ticks/labels, and host smoke-test support. +- Adds `LiveSPICE.Linux.sln` so Linux-specific projects can be built independently from the Windows WPF solution. +- Keeps Windows-facing paths unchanged relative to the `linux` base after the final cleanup commit. + +## Validation + +- `dotnet build LiveSPICE.Linux.sln --no-restore` +- Review fix validation: `dotnet test LiveSPICE.Avalonia.Tests/LiveSPICE.Avalonia.Tests.csproj --no-build --filter "FullyQualifiedName~PluginProgramParametersRoundTripProcessorSettings|FullyQualifiedName~LinuxPluginStateRoundTripsProcessorSettings|FullyQualifiedName~PluginEditorCreatesOverlayControlsForInteractiveSchematic|FullyQualifiedName~SimulationProcessorPassesThroughWhenNoSchematicIsLoaded|FullyQualifiedName~SimulationProcessorProcessesLoadedRcSchematic|FullyQualifiedName~SimulationAndAudioTests" --logger "console;verbosity=minimal"` passed 17 tests. +- Focused Avalonia tests for schematic interaction, settings, and menu-open behavior passed: 17 tests. +- Native LV2 build/test/UI smoke/install flow passed with `make -C Native/LiveSPICE.LV2 clean all test ui-smoke install`. +- Carla was able to load the generic LV2 plugin with `carla-single lv2 https://livespice.org/plugins/generic`. +- Codacy analysis was clean on edited C# files where supported. `.sln`, `.xaml`, and `.csproj` files were reported unsupported by the configured Codacy tools. + A later Codacy retry for the review-comment fixes was blocked because the Codacy MCP install tool failed to create `.codacy/cli.sh` with `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string`. + +## Notes for reviewers + +The Linux GUI/plugin work is intentionally additive. Reviewers can focus on: + +- `LiveSPICE.Avalonia/*` +- `LiveSPICE.Avalonia.Tests/*` +- `LiveSPICE.PluginCore/*` +- `LiveSPICE.PluginLinux/*` +- `Native/LiveSPICE.LV2/*` +- `LiveSPICE.Linux.sln` + +The native generic LV2 plugin currently provides host-loadable state/UI plumbing and pass-through audio; full schematic DSP execution inside the native LV2 shell remains follow-up work. + +The existing Windows WPF application and Windows VST UI are not part of this port and were verified unchanged against the `linux` branch for `LiveSPICE.sln`, `LiveSPICE/`, `LiveSPICEVst/`, `MockVst/`, and `SchematicControls/`. 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`.