Skip to content

perf: optimize tree-shaking and real-time audio rendering #114

Description

@codeacme17

Summary

This PR performs a comprehensive performance overhaul of Echo UI, focusing on the two largest sources of overhead identified during the component-library audit:

  1. The published ESM bundle does not tree-shake effectively, causing small imports such as Button, Card, or a single icon to retain most of Echo UI and Tone.js.
  2. Real-time audio visualizations allocate large arrays, trigger React updates, and rebuild SVG nodes on every animation frame.

The changes in this PR improve:

  • Consumer bundle size and module isolation
  • Tree-shaking for individual components and hooks
  • Real-time audio sampling efficiency
  • Spectrogram and oscilloscope rendering
  • Drag interaction scheduling
  • Controlled-component render behavior
  • ResizeObserver lifecycle management
  • Waveform preprocessing and rendering
  • Shared audio fetching and decoding
  • Performance regression coverage

Motivation

Echo UI is intended to be a high-performance UI library for Web Audio applications. The current implementation works correctly, but several architectural patterns become expensive when applications render multiple real-time components or run on lower-end devices.

The performance audit found two release-blocking issues and several high-priority runtime issues.

Published bundle baseline

Before this work:

Consumer scenario Minified JS Gzip
Full dist/echo-ui.js 213,766 B 56,341 B
Import Button, excluding React only 474,811 B 115,244 B
Import Card, excluding React only 474,809 B 115,242 B
Import one icon, excluding React only 475,427 B 115,583 B
Import Button, excluding React and Tone 183,492 B 50,830 B

This showed that importing a basic component still retained Tone.js, D3-related code, and most of the library.

For comparison, bundling Button directly from the source module produced approximately 13.5 kB gzip. The difference was caused by the published package structure rather than the component itself.

Runtime baseline

A production example was measured in headless Chromium while running:

  • One Waveform
  • One Oscilloscope
  • Two Spectrogram instances

Over a three-second measurement window, the page produced:

  • 5,611 DOM mutation records
  • 905 removed DOM nodes
  • 1,810 added DOM nodes
  • 1,810 SVG path attribute updates
  • Approximately 1.07 seconds of main-thread task time
  • Approximately 0.78 seconds of script execution time

The test machine still maintained approximately 60 fps, but the active visualizations consumed a large part of the available frame budget. This leaves limited capacity for application logic, effects, layout, and lower-performance devices.

The drag controls also failed to coalesce high-frequency pointer input:

Component Synthetic move events RAF callbacks queued
Input 500 500
Knob 500 500
Slider 500 500

Changes

1. Restore package-level tree-shaking

  • Split the public API into explicit component, visualization, and hook entry points.
  • Keep the ESM build modular instead of publishing one pre-bundled module containing every component.
  • Build the UMD artifact separately from the tree-shakeable ESM output.
  • Prevent non-audio components from importing or retaining Tone.js.
  • Prevent basic controls and icons from retaining D3 visualization code.
  • Add package exports for focused imports where appropriate.
  • Preserve the existing root export for backward compatibility.
  • Ensure CSS remains correctly marked as a side effect while JavaScript modules remain tree-shakeable.

Expected import patterns include:

  • @nafr/echo-ui
  • @nafr/echo-ui/button
  • @nafr/echo-ui/card
  • @nafr/echo-ui/visualization/spectrogram
  • @nafr/echo-ui/hooks/use-player

The root entry remains available, but consumers can select narrower entry points when bundle isolation is important.

2. Add consumer bundle-size regression checks

Add production consumer fixtures that bundle representative imports:

  • Button
  • Card
  • A single icon
  • Spectrogram
  • usePlayer
  • Root package import

The verification checks ensure that:

  • A basic component does not contain Tone.js.
  • A basic component does not contain D3 visualization code.
  • A single icon does not retain the component library.
  • Hook entry points contain only their required runtime dependencies.
  • Published package exports resolve correctly in ESM and CommonJS consumers.
  • Bundle-size regressions fail CI instead of relying on manual inspection.

Target budgets:

Import Target gzip budget
Single icon < 2 kB
Basic control < 20 kB
Card < 20 kB
Audio hook, excluding Tone peer/runtime < 10 kB
Visualization component Component-specific budget documented in the fixture

3. Centralize real-time animation scheduling

Update useOscilloscope, useSpectrogram, useVuMeter, and usePlayer so they do not independently push unrestricted updates through React on every display frame.

The new scheduling behavior should:

  • Allow a configurable maximum update rate.
  • Avoid scheduling more than one pending animation frame.
  • Stop scheduling immediately after an error.
  • Stop scheduling when the hook is canceled or unmounted.
  • Pause or reduce work while the document is hidden.
  • Support offscreen suspension where an element reference is available.
  • Avoid publishing state when the sampled value has not changed meaningfully.
  • Separate audio sampling frequency from React rendering frequency.

Recommended defaults:

  • Oscilloscope: up to 30 fps
  • Spectrogram: up to 30 fps
  • VU meter: 30–60 fps, depending on smoothing
  • Player progress: 15–30 fps

Audio analysis may continue at the required internal rate, but React and DOM updates should be throttled independently.

4. Reduce per-frame analyzer allocations

Before this work, analyzer hooks converted typed arrays into newly allocated object arrays on every frame.

For example, a 1,024-point analyzer could allocate thousands of objects per second before the visualization performed its own transformation.

This PR should:

  • Keep analyzer output in typed arrays where possible.
  • Avoid Array.from(...).map(...) in frame loops.
  • Avoid recreating { index, amplitude } and { frequency, amplitude } objects every frame.
  • Reuse preallocated buffers when the analyzer size is unchanged.
  • Calculate frequency coordinates once when fftSize changes.
  • Avoid transforming the same spectrum independently for line and shadow rendering.

5. Reuse visualization nodes

Spectrogram

  • Stop removing and appending line and shadow groups on every frame.
  • Create static SVG structure during initialization.
  • Reuse the existing line and shadow paths.
  • Update only the required path data.
  • Reuse scales until dimensions or scale-related props change.
  • Calculate grid and axis layers only when their configuration or dimensions change.
  • Ensure gradient IDs remain unique across multiple component instances.
  • Avoid recalculating the same transformed spectrum for both line and shadow rendering.

Oscilloscope

  • Reuse a single path instead of replacing its parent group every frame.
  • Keep scales stable while dimensions, data length, and amplitude range are unchanged.
  • Avoid recreating curve generators during each data update.
  • Prefer a typed-array or index-based path generator without intermediate object allocation.

Rendering backend

Where SVG path updates remain too expensive, evaluate Canvas or OffscreenCanvas for real-time layers while retaining SVG for static axes and labels.

Acceptance criteria:

  • Stable playback produces no per-frame childList mutations.
  • Grid and axis nodes are not recreated by audio data updates.
  • Each visualizer has at most one live render task per frame.
  • Multiple visualizer instances do not share conflicting SVG IDs.

6. Coalesce drag interactions

Update Input, Knob, and Slider to use a single-frame pointer scheduler.

Changes include:

  • Use Pointer Events instead of separate document-level mouse handlers.
  • Use pointer capture to keep drag ownership on the active control.
  • Store only the latest pointer coordinates.
  • Allow at most one pending RAF callback.
  • Cancel the pending callback on drag end and unmount.
  • Remove global listeners during cleanup.
  • Avoid calling onChange more than once per animation frame.
  • Preserve a final synchronous value update for onChangeEnd.

Acceptance criteria:

  • A burst of 500 pointer events queues no more than one pending RAF.
  • No pointer listener or RAF callback survives component unmount.
  • onChangeEnd receives the final clamped value.
  • Drag behavior remains correct for horizontal, vertical, and bilateral controls.

7. Remove render-phase state updates

Remove setState calls from render-time calculations and useMemo.

Affected behavior includes:

  • Input setting direction while calculating its background
  • Slider setting direction while calculating progress styles
  • Controlled values mirrored into local state through effects
  • Values that can be derived directly from props or existing state

Direction and other derived values should be calculated as plain variables unless they represent independent user state.

Acceptance criteria:

  • No component calls a state setter during render.
  • Updating a controlled value requires no more than one component commit.
  • React Strict Mode produces no render-phase update warnings.
  • Controlled and uncontrolled behavior remains consistent.

8. Simplify render waterfalls

Reduce prop-to-state synchronization in:

  • Checkbox
  • Input
  • Knob
  • Light
  • Switch
  • VuMeter
  • Waveform

Examples:

  • Light should render directly from on instead of copying on into state.
  • Waveform should not copy percentage through an effect when it is controlled.
  • VuMeter should derive an active index from value rather than storing a complete array of binary lump values.
  • Knob should derive rotation from the current value without a second effect-driven render.
  • Group-controlled checkbox and radio state should not require a child-local synchronization pass.

9. Optimize VU meter rendering

The VU meter currently rebuilds arrays of 30 or 60 lump values and traverses every rendered span for each audio update.

This PR should:

  • Store or derive a numeric active-lump boundary.
  • Avoid allocating full binary arrays for every sample.
  • Keep Context values stable.
  • Memoize group-level values and handlers.
  • Update only lump nodes whose active range changed.
  • Review the 150ms color transition, which can remain continuously active during 60fps meter updates.
  • Ensure stereo rendering does not duplicate avoidable work.

Acceptance criteria:

  • One meter value update produces at most one React commit.
  • No full binary lump array is allocated per frame.
  • Stable meter input causes no React update.
  • Stereo and mono output preserve their current visual thresholds.

10. Optimize Waveform rendering and interaction

  • Avoid rendering two complete copies of the waveform path for progress masking.
  • Reuse one waveform definition with an SVG clip path or mask where possible.
  • Avoid animating large SVG d attributes during initialization.
  • Cache the component bounds during pointer entry or resize.
  • Do not call getBoundingClientRect() for every mouse move.
  • Coalesce hover updates to one update per frame.
  • Move the cursor using transform: translateX(...) instead of left.
  • Avoid three independent state updates for each pointer move.
  • Respect reduced-motion preferences for the initial waveform animation.

Acceptance criteria:

  • Playback progress updates do not rebuild waveform paths.
  • Hovering does not cause repeated forced layout reads.
  • The waveform cursor updates through transform-only styles.
  • A progress tick produces at most one React commit.

11. Fix ResizeObserver scheduling

The previous throttle implementation invoked the callback immediately and then invoked it again after 100ms even when no additional resize occurred. Pending timeouts were not canceled during cleanup.

This PR should:

  • Replace the custom throttle with a cancellable scheduler.
  • Avoid unconditional trailing invocations.
  • Keep the latest callback in a ref or require a stable callback.
  • Disconnect the observer and cancel pending work during cleanup.
  • Remove mutable data objects from observer-effect dependencies.
  • Trigger one redraw for one stable resize event.

Affected components include:

  • Envelope
  • LFO
  • Oscilloscope
  • Spectrogram
  • Waveform

12. Optimize Envelope drag rendering

  • Reuse existing path and circle nodes.
  • Avoid removing and recreating every node during controlled feedback.
  • Avoid rebinding D3 drag handlers on every value update.
  • Keep transient drag coordinates outside React where appropriate.
  • Publish at most one onChange callback per frame.
  • Commit normalized envelope data on drag end.
  • Avoid recreating the ResizeObserver whenever envelope data changes.

13. Move waveform preprocessing off the main thread

useWaveform currently walks all PCM frames synchronously when a new AudioBuffer is supplied.

This PR should:

  • Support Worker-based peak generation for long audio.
  • Yield or process in chunks when Worker execution is unavailable.
  • Cache results by audio source, channel count, and requested sample count.
  • Reuse previously generated peak levels when changing visualization width.
  • Document the behavior for very large or long audio files.

Acceptance criteria:

  • Long audio processing does not create a main-thread task longer than 50ms on the target benchmark device.
  • Switching back to an already processed source reuses cached data.
  • Changing only playback progress does not rerun waveform preprocessing.

14. Deduplicate audio fetching and decoding

Improve useFetchAudio so multiple components using the same URL do not independently download and decode identical audio.

Possible implementation:

  • Add an optional shared request/decode cache.
  • Deduplicate in-flight requests.
  • Cache resolved AudioBuffer values with explicit invalidation.
  • Reuse a decoding AudioContext instead of creating one context per request.
  • Preserve AbortSignal and request-version behavior.
  • Ensure failed and aborted requests are not retained as successful cache entries.

This behavior should remain opt-in or clearly documented if global caching would change existing lifecycle expectations.

15. Stabilize Context and class-generation work

  • Memoize Context values in Button, Checkbox, Radio, Knob, and VU meter groups.
  • Stabilize group change handlers with useCallback.
  • Avoid propagating group rerenders when inherited values have not changed.
  • Avoid running tailwind-merge repeatedly inside frame-driven components when class inputs are static.
  • Precompute static style-slot output outside hot render paths where possible.

Component impact

Component Main performance concern
Button / Button.Group Package retention, Context identity, class merging
Checkbox / Checkbox.Group Mirrored controlled state, Context propagation
Envelope Drag feedback, SVG rebuilding, observer recreation
Input Uncoalesced drag events, render-phase direction updates
Knob / Knob.Group Multiple render stages per value, uncoalesced drag events
Radio / Radio.Group Context identity and prop-merging allocations
Slider Uncoalesced drag events and layout-property updates
Switch Mirrored state and left animation
VuMeter Per-frame arrays, double rendering, Context churn
Light Mirrored state, synchronous color parsing, expensive paint effects
LFO Fixed 1,000-point sampling and full SVG redraw
Spectrogram Per-frame allocation and SVG node replacement
Oscilloscope Per-frame allocation and SVG node replacement
Waveform Duplicate SVG paths, layout reads, progress render waterfall
Axis Two-stage layout measurement and full axis recreation
Card No component-specific hot loop; affected by package retention
Icons No component-specific runtime issue; affected by package retention

Backward compatibility

  • Preserve the existing root package entry.
  • Preserve current component props unless a change is explicitly documented.
  • Preserve existing controlled-component semantics.
  • Keep existing Tone node access through hook refs.
  • Keep existing CSS entry points.
  • Treat new focused exports as additive.
  • Document any new scheduling props such as maxFps, pauseWhenHidden, or cache configuration.

Testing

Existing checks

  • TypeScript typecheck passes
  • ESLint passes with zero warnings
  • Existing unit tests pass
  • React 18 consumer smoke test passes
  • React 19 consumer smoke test passes
  • Tone browser smoke tests pass
  • Tailwind consumer smoke tests pass
  • Package artifact verification passes
  • Example production build passes
  • Documentation production build passes

New performance regression tests

  • Bundle budget for Button
  • Bundle budget for Card
  • Bundle budget for a single icon
  • Bundle budget for usePlayer
  • Verify non-audio imports exclude Tone
  • Verify basic controls exclude D3 visualization code
  • Render-count test for controlled Light
  • Render-count test for controlled Waveform
  • Render-count test for VuMeter
  • RAF coalescing test for Input
  • RAF coalescing test for Knob
  • RAF coalescing test for Slider
  • Observer cleanup test after runtime errors
  • Hidden-document pause test
  • ResizeObserver single-callback test
  • Long-audio waveform preprocessing benchmark
  • Multi-instance visualization browser benchmark

Browser validation

Test the following scenarios in a production build:

  1. One active oscilloscope
  2. One active spectrogram with shadow
  3. Mono VU meter
  4. Stereo VU meter
  5. Waveform playback progress
  6. Two spectrograms, one oscilloscope, and one waveform running together
  7. Twenty simultaneous VU meters
  8. Rapid Input, Knob, and Slider dragging
  9. Components entering and leaving the viewport
  10. Page visibility switching between visible and hidden

Performance acceptance criteria

  • Importing a basic control does not include Tone.js.
  • Importing a basic control does not include D3 visualization modules.
  • A stable audio frame creates no visualization childList mutations.
  • One pointer burst creates at most one pending RAF callback per control.
  • Errors stop analyzer observation loops.
  • Unmounting cancels RAF, observer, timeout, and document listeners.
  • Controlled value updates require no more than one component commit.
  • No state setter runs during render.
  • Grid and axis layers are not rebuilt by audio-frame updates.
  • Long waveform preprocessing avoids main-thread tasks over 50ms.
  • Repeated audio URLs can reuse in-flight and decoded results.
  • Multi-visualizer benchmarks stay within the agreed frame and scripting budgets.

Before/after results

Update this table with the final measurements before merging:

Metric Before After Target
Button consumer gzip, React excluded 115.24 kB TBD < 20 kB
Button consumer gzip, React and Tone excluded 50.83 kB TBD < 20 kB
Full ESM gzip 56.34 kB TBD No regression
Four active visualizers: DOM mutations / 3s 5,611 TBD No per-frame child replacement
Four active visualizers: main-thread task time / 3s 1.07s TBD Material reduction
Four active visualizers: script time / 3s 0.78s TBD Material reduction
Input RAF callbacks for 500 move events 500 TBD ≤ 1 pending
Knob RAF callbacks for 500 move events 500 TBD ≤ 1 pending
Slider RAF callbacks for 500 move events 500 TBD ≤ 1 pending

Risks

  • Changing package exports may affect resolution in older bundlers.
  • Moving from object arrays to typed arrays may require internal type changes.
  • Reducing UI update frequency can change the visual feel of meters.
  • Canvas rendering can affect styling and testing strategies.
  • Audio caching changes ownership and memory-lifetime expectations.
  • Pointer Events require regression testing for mouse, touch, and pen input.
  • Removing mirrored state can expose previously ambiguous controlled/uncontrolled behavior.

These areas should be covered by compatibility fixtures, browser tests, and release notes.

Documentation

  • Document focused package entry points
  • Document real-time update-rate defaults
  • Document analyzer lifecycle and visibility behavior
  • Document optional audio caching
  • Document long-audio waveform processing
  • Add performance guidance for multiple simultaneous visualizers
  • Add release notes with any observable behavior changes

Related issue

Closes #

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions