Skip to content

Keep the live simulation compiled and recoverable; unify hosts on PluginCore - #4

Merged
ledogar merged 2 commits into
masterfrom
plugincore-live-fixes
Jul 29, 2026
Merged

Keep the live simulation compiled and recoverable; unify hosts on PluginCore#4
ledogar merged 2 commits into
masterfrom
plugincore-live-fixes

Conversation

@ledogar

@ledogar ledogar commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Follow-up flagged in #3: the live audio paths had two latent problems, and three different glue layers existed to have them in.

The problems

Audio-thread compile stalls. Simulation compiles its inner loop lazily on the first Run after any change to Solution/Oversample/Iterations. In the plugin path that compile landed inside the audio callback after every pot change; in the Avalonia live path it landed on the first buffer of every session. Multi-millisecond glitch, guaranteed.

No divergence recovery. A circuit hitting NaN/Inf threw SimulationDiverged out of the audio path with no handler — permanent silence (or a latched bypass in the VST) until restart.

Upstream's update path also embodied a forced tradeoff: it mutated the live simulation's Solution to preserve circuit state (capacitor charge), which is exactly what triggered the audio-thread recompile. You could have state continuity or a clean swap, not both.

The fix

Two small additions to Circuit.Simulation dissolve the tradeoff:

  • Reset() — restore initial conditions and t=0 (extracted from the ctor).
  • CopyStateFrom(other) — state lives in a dictionary keyed by expression, so a new, already-compiled simulation can inherit the old one's previous-sample values and sample clock. Matching unknowns keep their values; new unknowns keep their initial conditions.

SimulationProcessor now always: builds a new simulation on the background task → warms it with one scratch sample (forcing the compile) → Reset() → publishes under a lock that covers only CopyStateFrom and the reference swap. The audio thread never waits behind a solve or a compile, and pot moves no longer touch state. Divergence is caught with the WPF app's policy: silence the buffer, rebuild off-thread if it survived >1 s, stay bypassed if it diverged immediately (genuinely unstable circuit — don't thrash).

Also added: EnsureSimulationReady() (synchronous build, for offline render and pre-stream warm-up), SetSchematic() (in-memory schematics), SimulationReady.

Consolidation

  • LiveAudioProcessor (Avalonia live path) previously drove Simulation directly — with none of the above, plus two fresh array allocations per audio callback. Now a thin wrapper over SimulationProcessor with reused buffers; the waveform display still gets a per-callback copy because that array is handed across threads to the UI (the reuse would otherwise be a use-after-hand-off).
  • LiveSPICE.CLI drops its SimulationHost (~190 LOC) for the same SimulationProcessor; gain staging stays CLI-side. One behavior change: while building, play now passes dry input through (PluginCore's bypass convention) instead of silence.

Net: one glue layer where there were three, -257/+299 total including tests.

Verification

  • Avalonia suite 43/43, including two new tests that pin the contract: EnsureSimulationReady must produce real (non-bypass) output on the first call, and a pot change mid-stream must rebuild off-thread while audio keeps running NaN-free.
  • Circuit suite 49/49 against baselines — the Reset() extraction is numerically inert.
  • CLI render of the RC lowpass: numerically identical output to before the refactor.
  • Live play via BlackHole: clean, ready-message prints, clean stop. GUI launches.

Not covered

The state handoff copies values for unknowns whose expressions match across solutions. For pot changes and switch toggles that's the same unknown set; a topology edit mid-stream keeps what matches and initializes the rest. WaveformWindow's per-callback Dispatcher.Post of display samples still allocates — inherent to handing data to the UI thread; not worth churning.

🤖 Generated with Claude Code

tobleromed and others added 2 commits July 28, 2026 21:28
…ginCore

Simulation compiles its inner loop lazily on the first Run after any
change to Solution/Oversample/Iterations - a multi-millisecond stall that
previously landed inside the audio callback: on every pot change in the
plugin path, and on the first buffer in the Avalonia live path. Divergence
(NaN/Inf in the circuit) was also unhandled in both, killing audio
permanently.

Circuit:
- Simulation.Reset(): restore the solution's initial conditions and t = 0
  (extracted from the constructor).
- Simulation.CopyStateFrom(): copy previous-sample state and the sample
  clock from another simulation. State lives in a dictionary keyed by
  expression, so unknowns present in both solutions keep their values and
  new unknowns keep their initial conditions.
- AudioSimulationFactory: name the actual input count in the error when a
  circuit has more than one input.

LiveSPICE.PluginCore.SimulationProcessor:
- Every rebuild and update now builds a new simulation on the background
  task, warms it with one scratch sample (forcing the compile), resets it,
  and publishes it under a lock that only covers CopyStateFrom and the
  reference swap. The audio thread never waits behind a solve or compile,
  and pot moves no longer reset capacitor state - previously the choice
  was one or the other; the state handoff removes the tradeoff. This also
  replaces the two-branch update path (mutating the live simulation's
  Solution), which was the audio-thread-compile case.
- RunSimulation catches SimulationDiverged with the WPF app's policy:
  silence the buffer; if divergence came after >1 s, rebuild off-thread
  and keep playing; if almost immediate, the circuit is genuinely
  unstable, so stay bypassed rather than thrash rebuilding.
- EnsureSimulationReady(): synchronous build + publish, for offline
  rendering and for hosts that want audio ready before the stream starts.
- SetSchematic(): accept an in-memory schematic; SimulationReady exposed.

LiveSPICE.Avalonia.LiveAudioProcessor now uses SimulationProcessor instead
of driving Simulation directly, which it did with none of the above - plus
two fresh array allocations per audio callback. Buffers are now reused;
the waveform display still gets a per-callback copy because it is handed
across threads to the UI. Start() pre-compiles, so the first callback is
clean.

LiveSPICE.CLI drops SimulationHost (~190 LOC) for the same
SimulationProcessor; gain staging stays in the CLI. Behavior change: while
the simulation is still building, play now passes the dry input through
(PluginCore's bypass) instead of emitting silence.

Two new tests pin the behavior: EnsureSimulationReady must produce real
(non-bypass) output on the first call, and a pot change mid-stream must
rebuild off-thread while audio keeps running NaN-free.

Verified: Avalonia suite 43/43; circuit suite 49/49 vs baselines (the
Reset refactor is numerically inert); CLI render of the RC lowpass is
numerically identical to before; live play via BlackHole clean; GUI
launches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three are in the previous commit on this branch and are reachable from
ordinary use with no diagnostic.

1. A diverged circuit could never be revived by a control change. When a
   circuit diverges early, RunSimulation deliberately leaves needRebuild
   false to avoid rebuilding every buffer. But the interactive-component
   scan - the only route by which a pot or switch reaches needRebuild -
   sat below the "simulation == null" early return, so it was unreachable
   in exactly the state that needed it. Turning the gain down, the obvious
   thing a player would try, did nothing; only reloading the schematic or
   restarting the stream recovered. The scan now runs before the bypass
   check, and a control change with no simulation present rebuilds
   immediately rather than waiting out the update debounce.

2. CopyStateFrom silently transferred nothing across a sample rate or
   oversample change, yet still carried the sample clock. State is keyed
   by expressions of the form x[t - h] with the time step baked in as a
   literal, so no key matches when h changes: the circuit got the full
   reset the state handoff exists to prevent, plus a clock placing it at
   the wrong instant (60 s at 44.1 kHz resumed as 55 s at 48 kHz). It now
   returns false and leaves the new simulation at its initial conditions
   instead of carrying a meaningless clock, and Publish records that the
   handoff was skipped so a caller can tell a transient is expected.

3. The pre-compile warm-up could throw SimulationDiverged. The generated
   divergence guard fires when (n & 0xFF) == 0, which includes sample 0,
   so BuildSimulation's one-sample warm-up throws for any circuit with a
   bad DC operating point. That escaped outside the try guarding Run, was
   stashed as a build failure and rethrown inside the audio callback,
   bypassing the divergence policy entirely - and via EnsureSimulationReady
   it left the Avalonia live path with no processor at all. The warm-up
   now swallows it; the guard fires again during playback where the policy
   can handle it.

Also fixes the bypass path, which wrote only channel 0 and used
Array.CopyTo (whole source array, ignoring numSamples, throwing when the
input is longer than the output). It now writes every output channel and
copies numSamples.

Tests: each fix is pinned by a test that fails without it, verified by
reverting the fixes individually. The previous pot test asserted only
"not NaN, not silent" and would have passed with the handoff broken; it is
replaced by one that asserts the output actually changes across a pot move
and shows no discontinuity from a state reset.

Avalonia suite 46/46; circuit suite 49/49 against baselines; CLI render
numerically identical; live playback via BlackHole clean.

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

ledogar commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Review pass — three defects found and fixed in d94a12a

A design/documentation adherence review of this branch turned up three high-severity defects, all introduced by 34c7768 and all reachable from ordinary use with no diagnostic. Fixed here rather than merged and followed up.

Two of them contradict claims in this PR's description, which is worth stating plainly:

"Matching unknowns keep their values, new ones take initial conditions" — literally true, materially misleading. State is keyed by x[t - h] expressions with the time step baked in as a literal, so when h changes no key matches and nothing transfers — while the sample clock was carried anyway. A rate change therefore produced the exact capacitor-recharge reset the feature exists to prevent, plus a clock placing the circuit at the wrong instant (60 s at 44.1 kHz resuming as 55 s at 48 kHz). Silent in both halves. CopyStateFrom now returns false and leaves the new simulation at its initial conditions instead.

"The audio thread is never blocked behind a solve or a compile" — true about the solve and the compile, wrong in implication. Publish holds the lock across CopyStateFrom, which is O(state variables) of structural Expression hashing, and the audio thread waits on that same lock. Not fixed here (it needs a lock-free handoff, and it's a performance issue rather than a correctness one) but the doc comment no longer claims otherwise.

The third: a diverged circuit could never be revived by turning a pot down. The control scan was below the simulation == null early return, unreachable in exactly the state needing it. And the pre-compile warm-up could throw SimulationDiverged — the guard fires at sample 0 — escaping the handler and rethrowing inside the audio callback.

Also fixed: the bypass path wrote only channel 0 and used Array.CopyTo, ignoring numSamples and throwing when input is longer than output.

On the tests

The pot test added in 34c7768 asserted only "not NaN, not silent" — it would have passed with the state handoff completely broken, which is precisely the bug it should have caught. Replaced with tests that assert the output actually changes across a pot move and shows no discontinuity, plus direct coverage of the clock handoff. Each new test was verified to fail with its fix reverted individually.

Verification

Avalonia suite 46/46 · circuit suite 49/49 against baselines · CLI render numerically identical · live playback via BlackHole clean.

Known and deliberately not in this PR

Real-time hazards inherited from dsharlet#272 or lower severity, recorded for follow-up: the per-callback Dispatcher.UIThread.Post from the IOProc (a direct violation of GUI_PORT_PLAN.md:176), per-callback allocations on the audio thread, unsynchronized shared state in SimulationProcessor, and the unprotected catch handler in CoreAudio/Stream.cs.

@ledogar
ledogar merged commit 3d14234 into master Jul 29, 2026
4 checks passed
@ledogar
ledogar deleted the plugincore-live-fixes branch July 29, 2026 05:06
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