Skip to content

Reduce main-thread CPU during GUI interaction (especially on low-power devices)#33

Merged
Frieve-A merged 9 commits into
Frieve-A:mainfrom
takaeda:optimize/pause-disabled-animations
May 28, 2026
Merged

Reduce main-thread CPU during GUI interaction (especially on low-power devices)#33
Frieve-A merged 9 commits into
Frieve-A:mainfrom
takaeda:optimize/pause-disabled-animations

Conversation

@takaeda
Copy link
Copy Markdown
Contributor

@takaeda takaeda commented May 27, 2026

Reference implementation for #32. Discussion / direction lives in the
issue; per-commit review here. Each commit is independently
reviewable / revertible.

Background

On underpowered hardware — Raspberry Pi 5 was the trigger — users hear
frequent audio dropouts ("crackles") while interacting with the EffeTune
UI, especially when scrolling the pipeline area. A DevTools Performance
profile on a Pi 5 attributed almost all of the additional cost to the
main thread, where several independently small inefficiencies added up
to enough load to starve the audio render thread of its 2.6 ms budget.

The actual DSP is fine; the cost is in the GUI.

This PR collects a series of small, focused changes to the GUI that
together eliminate that pressure. On capable hardware the changes are
functionally invisible — the UI behaves identically and the cost is
negligible. On a Pi 5 they make the difference between audible glitches
during scrolling and clean playback.

See #32 for the full investigation.

Changes (commit-by-commit)

  1. Pause analyzer animation loops while the plugin is disabled
    PluginBase.setEnabled() now drives startAnimation() /
    stopAnimation() so any plugin with a per-frame redraw loop pauses
    automatically when toggled OFF.

  2. Route plugin enable/disable through setEnabled() consistently
    the pipeline's ON button and the preset/URL deserializer now call
    plugin.setEnabled() instead of writing plugin.enabled directly.

  3. Pause analyzer animations inside a section when the section is
    OFF
    — toggling a Section walks the inner plugins and notifies
    each one, mirroring the worklet's section-active gating on the
    main thread.

  4. Guard every startAnimation() against running while disabled
    prevents the IntersectionObserver from quietly restarting a redraw
    loop after a scroll round-trip on a plugin that is OFF.

  5. Eliminate per-frame allocations in 5-Band Dynamic EQ graph redraw
    — the static frequency axis is precomputed once; the dynamic
    response is computed and stroked in a single pass. Removes ~1,000
    short-lived Float entries per frame.

  6. Use CSS containment on each pipeline plugin card
    (contain: layout style paint) — lets the browser skip hit-testing
    and re-painting off-screen plugin cards while scrolling.

  7. Honor enclosing Section's enabled state when (re)starting
    animations
    — adds plugin._sectionEnabled so that subsequent
    startAnimation() calls (e.g. the IntersectionObserver firing on
    scroll-in) keep respecting the section's OFF state.

  8. Add IntersectionObserver to 5-Band Dynamic EQ — the plugin
    previously redrew its dynamic graph at 60 Hz even when scrolled
    completely off-screen.

  9. Replace per-frame getBoundingClientRect with IntersectionObserver
    in Compressor / Expander / Gate / Multiband Compressor / Multiband
    Expander — eliminating five forced synchronous-layout calls per
    frame.

Effect

On a Raspberry Pi 5 with a real-world pipeline:

  • Toggling a plugin or a Section OFF now actually frees main-thread
    CPU (the redraw loop stops, instead of running invisibly).
  • Main-thread CPU consumed during scrolling drops substantially.
    Plugin cards outside the viewport no longer participate in
    per-frame paint/hit-test work, and several per-frame
    getBoundingClientRect() calls are gone.
  • Audible glitches during UI interaction (clicks during scroll, ON/OFF
    toggles, parameter sweeps) are largely eliminated on this hardware.

On capable hardware the same changes have no perceptible effect on
behavior or performance.

Compatibility

  • No visual changes.
  • No public API changes. setEnabled() and
    startAnimation()/stopAnimation() were already the conventions
    analyzer plugins used; this PR simply makes them the canonical
    enable/disable path.
  • Plugins that don't expose startAnimation/stopAnimation are
    unaffected.
  • No new dependencies. IntersectionObserver and CSS contain are
    baseline-supported by the Chromium versions EffeTune ships with.

Risk

Localized. Each change is small, scoped to one of:

  • PluginBase (one new flag, one new method, one routing fix)
  • the pipeline's ON-button click handler
  • serialization-utils.js (one routing fix)
  • per-analyzer startAnimation() (additional guard)
  • per-analyzer createUI (IntersectionObserver wiring)
  • one CSS rule on .pipeline-item

No part of the audio path (worklet, signal flow, sample rate handling)
is touched.

takaeda added 9 commits May 27, 2026 19:35
Analyzer-style plugins (Spectrum Analyzer, 5-Band Dynamic EQ,
Oscilloscope, Stereo Meter, Level Meter, Spectrogram, ...) start a
requestAnimationFrame loop that redraws their canvas at the display
refresh rate. Until now that loop kept running even when the user
toggled the plugin OFF via the ON button -- the audio processing was
skipped, but the canvas redraw kept burning main-thread CPU at 60 Hz
per disabled instance.

On a Raspberry Pi 5 a DevTools profile of a typical pipeline showed
the redraw of disabled analyzers as one of the largest main-thread
consumers (e.g. Spectrum Analyzer's animate at 2.4% of total time,
5-Band Dynamic EQ's at 2.2%, Oscilloscope's at 0.8%) even though
they were "OFF". Reducing main-thread CPU helps the audio worklet
get its render slice on time on low-power hardware, which is the
direct cause of the dropouts these users hear.

Wire the existing per-plugin startAnimation()/stopAnimation() pair
into PluginBase.setEnabled() so any plugin that follows that naming
convention automatically pauses its redraw loop when disabled. No
change for plugins that don't expose those methods.
Previously the pipeline's ON button and the preset/URL deserializer
assigned plugin.enabled directly, bypassing PluginBase.setEnabled().
That meant the side-effects the base class hooks into setEnabled
(such as pausing analyzer redraw loops on disable) never ran.

Switch both call sites to plugin.setEnabled() so the transition runs
through one path.
Toggling a Section plugin's ON button bypasses every plugin in that
section on the worklet side, but on the main thread the affected
plugins still keep their per-frame redraw loops running -- the
section's enabled flag changes, but the inner plugins' enabled flag
does not.

After toggling a Section, walk the pipeline from the toggled Section
up to (but not including) the next Section, and stop/start each
inner plugin's animation loop accordingly:

- Section turned OFF: stopAnimation on every inner analyzer plugin.
- Section turned ON:  startAnimation on every inner analyzer plugin
  whose own ON state is also enabled.

The section range matches what the worklet's section-active gating
uses (plugins/audio-processor.js).
Previously, the per-frame redraw loop could be re-started without
checking the plugin's enabled flag. Two paths exposed this:

- IntersectionObserver.handleIntersect calls startAnimation() when
  the canvas scrolls back into view, without checking enabled.
- Section propagation calls startAnimation() on inner plugins that
  may be in any enabled state.

Add 'if (!this.enabled) return;' at the top of every analyzer-style
plugin's startAnimation() (15 plugins) so that the loop can only ever
be started while the plugin is actually enabled. stopAnimation()
remains the right hook to also tear down an existing loop on disable
(called from PluginBase.setEnabled() in a prior commit).
The dynamic graph is repainted at the display refresh rate (60 Hz)
while the plugin is enabled, and a Performance profile on Raspberry
Pi 5 showed _drawGraph as the largest single source of main-thread
load (~5% of CPU). Two array allocations per frame (~1,000 Float
elements total) were the dominant contributors:

- freqPoints: a 501-element array of frequency-axis sample points,
  fully determined by the (constant) minFreq/maxFreq range.
- responsePoints: a 501-element array materializing the per-point
  combined EQ response purely to feed the next loop that strokes
  the canvas path.

Plus a small but per-frame Math.log10(minFreq)/Math.log10(maxFreq)
recomputation in three independent loops, and a fresh zero-gain
fallback array each frame.

This change:

- Pre-builds a Float64Array of freqPoints once and reuses it; the
  range is static so it never needs to be recomputed.
- Caches log10(minFreq) and the log span as instance fields and
  uses them in all three coordinate calculations.
- Drops the intermediate responsePoints array: the combined-response
  curve is now computed and stroked in a single pass.
- Caches the zero-gain fallback array so the "no smoothed gains yet"
  branch no longer allocates.

No visual change. Per-frame allocation footprint of this redraw
drops from ~1,000 Float entries to zero.
A long pipeline contains many plugin cards stacked vertically. When
the user scrolls the pipeline area, the browser still has to walk
the entire DOM for hit-testing, style invalidation, and paint
decisions on cards that are outside the viewport, because none of
them declare any kind of containment.

Adding 'contain: layout style paint' to .pipeline-item tells the
browser that whatever happens inside one card cannot affect layout,
style, or paint outside it. The browser can then skip hit-testing
and re-painting off-screen cards entirely, which on a Raspberry Pi 5
is the difference between scrolling causing audio dropouts and not.

No visual change.
The previous "pause analyzer animations while disabled" change did
not survive a scroll round-trip when the plugin itself was ON but
its enclosing Section was OFF: when the canvas scrolled back into
view, the IntersectionObserver called startAnimation(), whose guard
only checked the plugin's own enabled flag and let the redraw loop
resume.

Track the section state per plugin and consult it from every
startAnimation() guard:

- PluginBase: new this._sectionEnabled flag (default true), with
  setEnabled() / _setSectionEnabled() / _refreshAnimationState()
  routing every transition through one place so the analyzer's
  redraw loop matches "this.enabled && this._sectionEnabled".
- pipeline-item-builder: Section toggle now calls
  plugin._setSectionEnabled(sectionOn) for every inner plugin so
  the cached section state stays consistent with the worklet.
- Every analyzer plugin's startAnimation() now guards on
  "!this.enabled || !this._sectionEnabled" instead of "!this.enabled".

Also picks up Spectrogram's process() guard the same way (which
is harmless and skips measurement handling under a disabled
section, reducing pointless GC work).
Spectrum Analyzer / Oscilloscope / Spectrogram already pause their
redraw loop when their canvas is scrolled out of view; 5-Band
Dynamic EQ did not, so its 60Hz _drawGraph kept running even when
the user could not see it.

Mirror the established pattern:
- An IntersectionObserver on the canvas updates this.isVisible.
- startAnimation() short-circuits if the canvas is hidden.
- The animate() body also checks isVisible per frame so an
  ongoing loop self-terminates when scrolled off-screen.
- cleanup() disconnects the observer.
Compressor, Expander, Gate, Multiband Compressor and Multiband
Expander all checked viewport visibility per animation frame by
calling getBoundingClientRect() on their canvas/container. That
call forces a synchronous layout flush, so it ran at 60 Hz per
instance and competed with scrolling for main-thread cycles.

Replace the per-frame call with an IntersectionObserver that
updates this.isVisible whenever the canvas/container crosses the
viewport boundary. The animate() body now consults the cached
flag instead, and stopAnimation() / startAnimation() are driven
from the observer.

Cleanup disconnects the observer.
@Frieve-A
Copy link
Copy Markdown
Owner

Merged.
Some minor additional fixes may be needed, but I will address them as soon as I find them.
Thank you for your PR.

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