Files
suaveplan/openspec/archive/2026-05-23-enterprise-react-audio-player/design.md
2026-05-27 23:29:53 +01:00

5.7 KiB

Design — enterprise-react-audio-player

@suaveplan/react-audio-player is a UI suite layered on @suaveplan/react-audio. Its job is to give a consumer a one-component drop-in player (<BasicPlayer> / <AudioPlayer> / <PremiumPlayer>) that takes a track URL or Playlist prop and renders a complete, accessible, beautiful audio player UI, with everything from the source plugin down handled by the unified architecture.

Cross-cutting architecture: see openspec/changes/enterprise-audio-fx-plugin-base/design.md and openspec/changes/enterprise-react-audio/design.md. This document covers only the player-specific concerns.


1. Player variants

Three player components share state via the audio-player-provider and a Zustand store, differing only in layout density and feature set:

  • <BasicPlayer> — single-track playback, play/pause/seek/volume, no chain, no visualiser. ~5 KB tree-shaken.
  • <AudioPlayer> — adds playlist, 3-band EQ, waveform visualiser, repeat/shuffle. Re-enabled from upstream "commented-out" state via proper subpath exports.
  • <PremiumPlayer> — adds spectrum analyser, R3F screensavers, karaoke (LRC lyrics), multi-peer playback sync via react-audio-stream.

The three sit on the same store and the same AudioPlayerPlugin. Switching components without unmounting the provider preserves playback state. Each variant is a separate subpath export; importing one does not pull in the others.


2. Per-player chain layout

The player constructs a fixed track on the timeline (player-track) with a fixed chain:

chain [
  AudioPlayerPlugin              ← source plugin (index 0)
  EQPlugin (3-band, in BasicPlayer this slot is absent)
  ...consumer-added FX slots...
]

<AudioPlayer> and <PremiumPlayer> expose a <PluginChainView chain> slot so consumers can add reverb, delay, etc. The chain owns the FX rack; the player owns the source.

The source plugin's 'ended' event drives playlist advance via usePlaylist; the playlist hook calls audioPlayerPlugin.load(nextSource) and audioPlayerPlugin.play().


3. LRC karaoke

LRC is parsed once into { timestampSec, text }[] and stored in useLyricsStore. <LyricsDisplay> reads useTransportTime() at frame rate and binary-searches the lyric list to find the active line. Active line highlight uses framer-motion with prefers-reduced-motion honoured.

const activeIndex = useMemo(
  () => binarySearchActive(lyrics, currentTimeSec),
  [lyrics, Math.floor(currentTimeSec * 10)] // re-bind every 100 ms
)

The binary search is O(log n); a 1000-line LRC re-evaluates in ~10 comparisons per 100 ms — well below 1 ms total.


4. Visualisers

<WaveformVisualizer> and <SpectrumAnalyzer> both consume useAnalyser(trackId | 'master') from react-audio. The hook samples the analyser at frame rate; the visualisers render to <canvas> (waveform) or <canvas> with FFT-bin-to-pixel mapping (spectrum). No new analyser instance is constructed; both share the chain's master analyser node.


5. R3F screensavers (optional)

<GLBViewer src animation?> and <ShaderBackground shader uniforms> are R3F components gated to the ./screensavers/* subpath. They are not pulled in unless explicitly imported. The screensaver wraps the player UI as a backdrop; opacity and visibility are React-controlled.

Three.js is a peer dep — non-screensaver consumers pay nothing.


6. Multi-peer sync (optional)

<PremiumPlayer multiPeer={{ roomId, peers }}> consumes useMultiPeerSync from this package, which in turn consumes hooks from @suaveplan/react-audio-stream to negotiate WebRTC peers, share transport play/pause/seek events across peers via a control channel, and stream the player's audio output to peers via the engine's tap-to-bus surface. The control protocol uses @suaveplan/audio-transport as the source of truth for transport state, so a peer disconnect does not desync the local player.

react-audio-stream is a peer dep of this subpath — basic/audio players don't pay for it.


7. Persistence

A PlayerPersistenceStore interface backs:

  • the playlist ({ tracks: Track[], position: number, repeat: 'off'|'one'|'all', shuffle: boolean }),
  • the EQ preset ({ schemaVersion, bands: [...] } validated against EQPlugin.schema),
  • the user's volume + mute state.

Default implementation: localStorage keyed on the provider's id prop. Consumers may inject a different store (cloud-sync, IndexedDB, SSR cookie). Reads return defaults; writes are debounced (300 ms).


8. Accessibility

Inherits everything from react-audio: keyboard, ARIA, reduced-motion. Player-specific additions:

  • <ProgressBar> is a role="slider" keyboard-seekable with arrow keys for 5 s, page-up/down for 30 s.
  • <Queue> is role="listbox" with up/down navigation and Enter to play.
  • <LyricsDisplay> exposes the active lyric as aria-live="polite" so screen readers announce lyric changes (configurable off via announceLyrics={false}).

9. Decisions and trade-offs

  • Three variants, one source plugin, one chain. Sharing the source + chain across variants means switching variants doesn't restart playback.
  • <AudioPlayer> and <PremiumPlayer> expose the chain via <PluginChainView>. Consumers extend FX without breaking the player UI.
  • LRC binary search re-binds every 100 ms. Frame-rate re-bind (every 16 ms) is unnecessary; lyrics don't change that fast.
  • Screensavers and multi-peer sync are opt-in subpaths. R3F and WebRTC peer deps are paid only by consumers that actually use them.
  • Persistence is injectable. Cloud sync, SSR session, IndexedDB — all the same interface; localStorage is just the default.
  • No new analyser node. Reuse the engine's master analyser; one instance per chain, not per visualiser.