Sync Tone.js Playback State with Redux or Vuex

This article explores efficient strategies for synchronizing the real-time audio playback state of Tone.js with global state management libraries like Redux or Vuex. Managing Web Audio API states alongside UI states presents unique challenges due to the high-frequency updates of audio scheduling versus the render cycles of web frameworks. You will learn how to handle this mismatch, implement event-driven updates, and optimize performance using throttled polling and middleware.

The Core Challenge: Audio Thread vs. UI Thread

Tone.js operates on the Web Audio API’s high-precision timeline, scheduling events ahead of time to ensure glitch-free audio playback. Redux and Vuex, on the other hand, are designed for UI state management where state changes trigger component re-renders.

Directly dispatching actions to your global store on every tick of the Tone.js Transport (e.g., trying to sync Tone.Transport.position at 60fps) will degrade application performance, causing UI lag and audio stuttering. To prevent this, you must separate high-frequency audio data from macro-level playback states.

Strategy 1: Sync Macro Playback States Only

The most robust strategy is to only sync discrete, macro-level playback states—such as started, stopped, paused, and bpm—with your global store.

Instead of tracking the exact playback position in Redux or Vuex, listen for Transport events and dispatch state updates only when the state transitions:

// Bind Tone.js Transport events to store dispatches
Tone.Transport.on('start', () => {
  store.dispatch('playback/setPlaying', true);
});

Tone.Transport.on('stop', () => {
  store.dispatch('playback/setPlaying', false);
  store.dispatch('playback/setProgress', 0);
});

Tone.Transport.on('pause', () => {
  store.dispatch('playback/setPlaying', false);
});

By keeping the global store informed only of play/pause/stop status, you minimize re-renders while allowing UI elements (like play buttons) to react correctly.

Strategy 2: Use RequestAnimationFrame for Position Polling

If you need to display a progress bar or playhead position in the UI, do not dispatch the position to Redux or Vuex on every frame. Instead, initiate a local UI polling loop only when the playback state is active.

  1. Store the isPlaying boolean in Redux/Vuex.
  2. In your UI component, watch the isPlaying state.
  3. If isPlaying is true, start a requestAnimationFrame loop inside the component to read directly from Tone.Transport.seconds or Tone.Transport.progress.
  4. Update a local component state or directly mutate a DOM/SVG element to move the playhead, bypassing the global store entirely for these high-frequency updates.
// Inside a Vue or React Component
function updatePlayhead() {
  if (store.state.playback.isPlaying) {
    const currentProgress = Tone.Transport.progress;
    progressBarRef.current.style.transform = `scaleX(${currentProgress})`;
    requestAnimationFrame(updatePlayhead);
  }
}

Strategy 3: Unidirectional Command Flow via Middleware

To keep a clean separation of concerns, use Redux middleware or Vuex actions as a bridge. The UI should never call Tone.js methods directly; instead, it should dispatch actions.

The Command Flow:

  1. UI Event: User clicks the play button.
  2. Dispatch Action: UI dispatches PLAY_REQUESTED.
  3. Middleware/Action Handler: Intercepts the action, calls Tone.Transport.start(), and then dispatches PLAY_SUCCESS.
  4. Store Update: Redux/Vuex updates isPlaying to true.
  5. UI Update: The play button changes to a pause icon.

This ensures that Tone.js remains the single source of truth for the audio state, while Redux/Vuex remains the single source of truth for the UI representation.

Strategy 4: Leverage Tone.Draw for Visual Sync

When you need UI events to sync precisely with scheduled audio events (such as flashing a beat indicator on a step sequencer), use Tone.Draw.

Tone.Draw schedules draw callbacks in sync with the Web Audio clock, steering clear of latency issues:

Tone.Transport.scheduleRepeat((time) => {
  // Schedule the audio event
  synth.triggerAttackRelease("C4", "8n", time);

  // Schedule the UI event to sync with the audio event
  Tone.Draw.schedule(() => {
    // Dispatch a light-weight action to flash a UI pad
    store.dispatch('sequencer/flashPad', currentStep);
  }, time);
}, "8n");