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