Keep the live simulation compiled and recoverable; unify hosts on PluginCore - #4
Conversation
…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>
Review pass — three defects found and fixed in
|
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.
Simulationcompiles its inner loop lazily on the firstRunafter any change toSolution/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
SimulationDivergedout 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
Solutionto 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.Simulationdissolve 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.SimulationProcessornow 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 onlyCopyStateFromand 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 droveSimulationdirectly — with none of the above, plus two fresh array allocations per audio callback. Now a thin wrapper overSimulationProcessorwith 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.CLIdrops itsSimulationHost(~190 LOC) for the sameSimulationProcessor; gain staging stays CLI-side. One behavior change: while building,playnow passes dry input through (PluginCore's bypass convention) instead of silence.Net: one glue layer where there were three,
-257/+299total including tests.Verification
EnsureSimulationReadymust 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.Reset()extraction is numerically inert.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-callbackDispatcher.Postof display samples still allocates — inherent to handing data to the UI thread; not worth churning.🤖 Generated with Claude Code