Skip to content

Add macOS support (Core Audio backend + headless player), and make the circuit tests assertive - #274

Closed
ledogar wants to merge 2 commits into
dsharlet:masterfrom
ledogar:macos-port
Closed

Add macOS support (Core Audio backend + headless player), and make the circuit tests assertive#274
ledogar wants to merge 2 commits into
dsharlet:masterfrom
ledogar:macos-port

Conversation

@ledogar

@ledogar ledogar commented Jul 29, 2026

Copy link
Copy Markdown

This adds macOS support alongside the existing Windows app rather than changing it. LiveSPICE.sln and every WPF/VST project are untouched; the macOS-specific projects build from a separate LiveSPICE.Core.sln. The Windows CI job in this PR passes unchanged, so the risk to existing paths should be close to zero.

It's two separable commits, and the first is useful on its own even if you don't want the audio work.

1. Portable core build, and tests that actually assert (c9d8b65)

The core is already portable — Util, Audio, Circuit, ComputerAlgebra are netstandard2.0 with exactly one Windows dependency: the kernel32!MoveFileEx P/Invoke in Schematic.Save, which is now File.Move(overwrite: true) under net8.0 (the in-source comment anticipated this). Tests was pinned to net6.0-windows only because of the System.Drawing plotting sidecar, now #if PLOTTING-guarded and excluded off Windows.

The more valuable half is the test change. test previously passed if nothing threw — it never compared against Tests/Stats/*.csv. There's now a --check mode that compares every variable's mean/min/max/rms against those baselines and exits non-zero on mismatch, wired into CI on both platforms.

Making that work required recovering how the baselines were generated, which wasn't recorded: --sampleRate 44100, all other options default. At any other rate they don't reproduce — Simulation.TimeStep is Solution.TimeStep * oversample = 1/SampleRate regardless of oversample, so the sample rate alone sets the grid. I recovered it by fitting the input-node row that's common to all 49 files. Worth recording somewhere permanent; it's now in the CI invocation and a doc comment.

With that, on windows-latest all 49 circuits match the committed baselines with max deviation exactly 0.

2. Core Audio backend and headless player (a29ff31)

CoreAudio/ implements the Audio contract with AUHAL via P/Invoke to the system frameworks, mirroring how WaveAudio P/Invokes winmm — no third-party native dependency, nothing checked in. It follows the existing patterns: Driver per Asio/Driver.cs (one device each, per-device try/catch), Stream per Asio/Stream.cs (render callback, not WaveAudio's polling thread).

A few decisions worth reviewing:

  • One AudioUnit drives everything — the output-bus render callback pulls input from bus 1 first, so there's a single clock and no ring buffer. This requires input and output on the same device, which Audio.Device.Open already assumes.
  • Non-interleaved float32, so each AudioBuffer maps 1:1 onto Audio.Util.LEf32ToLEf64/LEf64ToLEf32. Those helpers are unit-stride only; interleaved would need a new de-interleave helper.
  • Output is clamped before conversion — LEf64ToLEf32 doesn't clamp and neither does Core Audio.
  • The Driver constructor no-ops off macOS, so this builds and is inert on Windows.

LiveSPICE.CLI/ is a headless player: list, tone, render, play, loopback. It doesn't reuse LiveSPICEVst/SimulationProcessor.cs — that code is genuinely portable, but reusing it meant either duplicating it or restructuring a Windows-only csproj I can't build. If you'd prefer it factored into a shared project, that's an easy follow-up and probably the right long-term shape.

Its SimulationHost does add two things the VST path lacks, which may be worth porting back: it forces the Linq expression tree to compile on the build thread (Simulation.Run compiles on first call, so today the VST takes a multi-millisecond stall inside the audio callback after every pot change), and it recovers from SimulationDiverged the way LiveSimulation does.

Verification

On macOS 26.5 / Apple Silicon:

  • render of Passive 1stOrder Lowpass RC matches the analytic RC transfer function to 0.0002% in magnitude. The residual 0.29° phase lag is the half-sample delay from oversample output averaging (predicted 0.335°).
  • Big Muff Pi renders 30.4% THD with odd harmonics dominant, as expected for symmetric diode clipping, from a 0.008% THD input.
  • BlackHole loopback returns the played tone at full amplitude, rms exactly A/√2 — verifying enumeration, format negotiation, the render callback, both conversions and teardown.
  • Live play produces exactly 282 × 512 frames in 3 s at 48 kHz, no dropouts, clean stop. Tone from real speakers confirmed by ear.
  • Circuit suite passes 49/49 on macOS against the Windows-generated baselines, 45 of them to ≤1e-12.

That last point is the one I'd highlight: it means Expression.Compile() codegen, the Newton iteration and the Gauss-Jordan solver agree across OS, CPU architecture and SIMD width. Vector<double> is width-invariant here because the row operations are elementwise, so NEON at width 2 and AVX2 at width 4 give bit-identical results.

Known limitations

  • Pro Co Rat needs a loose tolerance (1e-1, documented in Test.cs). It's bit-identical run-to-run on each platform, but hard clipping in feedback deterministically amplifies ulp-level libm differences into a ~4e-2 trajectory shift. Windows matches its baseline exactly; macOS doesn't. I didn't want to hide this behind a global tolerance.
  • Input-only devices throw NotSupportedException pointing at Aggregate Devices. Duplex and output-only work. Mac built-in mic and speakers are separate Core Audio devices, so they need an aggregate.
  • No GUI. This is headless only — the WPF UI remains Windows-only. PR Add Linux GUI, audio, and LV2 plugin port #272 looks like the better starting point there, and Avalonia runs natively on macOS.
  • CoreAudio isn't in LiveSPICE.sln. Happy to add it if you want it built on Windows too (it's inert there).

Related: #262, #263, #202, #51.

🤖 Generated with Claude Code

tobleromed and others added 2 commits July 28, 2026 18:54
Build/port changes:
- Add LiveSPICE.Core.sln: the portable subset (Util, Audio, Circuit,
  ComputerAlgebra, Tests). LiveSPICE.sln and all WPF projects untouched.
- Tests: multi-target net6.0-windows;net8.0, collapsing to net8.0 off
  Windows. The System.Drawing/WinForms plotting sidecar (Tests/Plotting/)
  is excluded there and guarded with #if PLOTTING; --plot warns instead
  of crashing. Fix hardcoded '\' path separators in Stats/Plots output.
- Circuit: multi-target netstandard2.0;net8.0 and replace the
  kernel32!MoveFileEx P/Invoke in Schematic.Save with
  File.Move(overwrite: true) on net8+. This was the only Windows
  dependency in the core library.

Test-suite changes:
- Add a --check mode to the harness that compares each circuit's
  per-variable mean/min/max/rms against the committed baselines in
  Tests/Stats/, with deviations normalized by signal scale
  (max(|min|,|max|)), NaN-safe comparison, InvariantCulture I/O, and a
  nonzero exit code on mismatch.
- The committed baselines were generated at --sampleRate 44100 (all
  other options default); this configuration was recovered by fitting
  the shared input-node signature present in every baseline file. At
  that rate, macOS arm64 reproduces the Windows-generated numbers to
  <=1e-12 (scale-normalized) for 45 of 49 circuits and <=1e-9 for 48.
- Pro Co Rat is checked at a loose 1e-1 tolerance: its hard-clipping
  feedback deterministically amplifies platform ulp differences into a
  stable ~4e-2 trajectory shift (bit-identical run-to-run per platform).
- CI: add a macos-latest job building LiveSPICE.Core.sln, and run
  --check --sampleRate 44100 on both the Windows and macOS jobs so a
  green test run asserts numerical agreement rather than only
  "no exceptions".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was no working audio backend on macOS: WaveAudio is winmm P/Invoke
and Asio walks COM vtables against HKLM\SOFTWARE\ASIO. Both are Windows
only by construction, but the Audio abstraction they implement is a small
4-method contract that a new backend can satisfy directly.

CoreAudio/ implements that contract with AUHAL via P/Invoke to the system
frameworks, mirroring how WaveAudio P/Invokes winmm - no third party
native dependency. Notable points:

- One AudioUnit bound to one device drives everything: the output bus
  render callback pulls input from the input bus first, so there is a
  single clock and no ring buffer. This requires input and output to be
  the same device, which Audio.Device.Open already assumes.
- The stream format is non-interleaved float32, so each AudioBuffer maps
  1:1 onto Audio.Util.LEf32ToLEf64 / LEf64ToLEf32. Audio.Util's converters
  are unit-stride only; interleaved would need a new de-interleave helper.
- Buffers are allocated once at open. SampleBuffer pins its array for
  life, so allocating in the render callback would fragment the heap.
- The callback does no allocation, logging or locking, and cannot let a
  managed exception escape into the IOProc.
- Output is clamped before conversion: LEf64ToLEf32 does not clamp and
  neither does Core Audio.
- The Driver constructor no-ops off macOS, so this builds and is harmless
  on Windows.

Input-only devices throw NotSupportedException with a message pointing at
Aggregate Devices; duplex and output-only work.

LiveSPICE.CLI/ is a headless player with list, tone, render, play and
loopback commands. It deliberately does not reuse LiveSPICEVst's
SimulationProcessor: that code is portable, but it is built around the
VST's control model and using it would mean either duplicating it or
restructuring a Windows-only csproj. SimulationHost adds two things the
VST path lacks: it forces the Linq expression tree to compile on the
build thread (Simulation.Run compiles on first call, which would
otherwise be a multi-millisecond stall inside the audio callback), and it
recovers from SimulationDiverged the way LiveSimulation does.

Verified on macOS 26.5 arm64:
- list matches system_profiler exactly.
- render of Passive 1stOrder Lowpass RC matches the analytic RC transfer
  function to 0.0002% in magnitude; the 0.29 degree phase lag is the
  half-sample delay from oversample output averaging.
- Big Muff Pi renders 30.4% THD with odd harmonics dominant, as expected
  for symmetric diode clipping, from a 0.008% THD input.
- BlackHole loopback returns the played tone at full amplitude with rms
  exactly A/sqrt(2), verifying enumeration, format negotiation, the
  render callback, both conversions and teardown.
- 3 s of live play produces exactly 282 x 512 frames at 48 kHz with no
  dropouts and a clean stop.
- The circuit test suite still passes 49/49 against the baselines.

CI runs list and an offline render on the macOS job; the live path needs
hardware the runners don't have, which is why render exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ledogar

ledogar commented Jul 29, 2026

Copy link
Copy Markdown
Author

Apologies, PR created in error

@ledogar ledogar closed this Jul 29, 2026
@ledogar
ledogar deleted the macos-port branch July 29, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants