Reduce main-thread CPU during GUI interaction (especially on low-power devices)#33
Merged
Frieve-A merged 9 commits intoMay 28, 2026
Merged
Conversation
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.
Owner
|
Merged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
Pause analyzer animation loops while the plugin is disabled —
PluginBase.setEnabled()now drivesstartAnimation()/stopAnimation()so any plugin with a per-frame redraw loop pausesautomatically when toggled OFF.
Route plugin enable/disable through setEnabled() consistently —
the pipeline's ON button and the preset/URL deserializer now call
plugin.setEnabled()instead of writingplugin.enableddirectly.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.
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.
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
Floatentries per frame.Use CSS containment on each pipeline plugin card
(
contain: layout style paint) — lets the browser skip hit-testingand re-painting off-screen plugin cards while scrolling.
Honor enclosing Section's enabled state when (re)starting
animations — adds
plugin._sectionEnabledso that subsequentstartAnimation()calls (e.g. the IntersectionObserver firing onscroll-in) keep respecting the section's OFF state.
Add IntersectionObserver to 5-Band Dynamic EQ — the plugin
previously redrew its dynamic graph at 60 Hz even when scrolled
completely off-screen.
Replace per-frame
getBoundingClientRectwith IntersectionObserverin 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:
CPU (the redraw loop stops, instead of running invisibly).
Plugin cards outside the viewport no longer participate in
per-frame paint/hit-test work, and several per-frame
getBoundingClientRect()calls are gone.toggles, parameter sweeps) are largely eliminated on this hardware.
On capable hardware the same changes have no perceptible effect on
behavior or performance.
Compatibility
setEnabled()andstartAnimation()/stopAnimation()were already the conventionsanalyzer plugins used; this PR simply makes them the canonical
enable/disable path.
startAnimation/stopAnimationareunaffected.
IntersectionObserverand CSScontainarebaseline-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)serialization-utils.js(one routing fix)startAnimation()(additional guard).pipeline-itemNo part of the audio path (worklet, signal flow, sample rate handling)
is touched.