Skip to main content

State Management Architecture

For humans and LLMs contributing to Songbird.

Overview

Songbird uses a three-layer state system: React (UI) ↔ C++ (engine) ↔ Git (history). All state flows through a central bridge, and every meaningful change is committed to an in-process Git repo for undo/redo.

Files on Disk

Each project directory contains:
FileTrackedPurpose
daw.bird✅ GitComposition — notes, arrangement, structure
daw.state.json✅ GitUI state — mixer (volumes, pans, mutes, solos, sends)
daw.edit.json✅ GitPlugin state — VST3/AU presets as structured JSON
daw.session.json❌ GitignoredSession state — transport, chat, Lyria config
daw.edit.xml❌ GitignoredTracktion Engine native edit XML
Rule: Only git-tracked files participate in undo/redo. Session state is ephemeral.

Zustand Stores (React)

Four persisted stores, each synced to C++ via the juceBridge storage adapter:
StoreIDPersisted To
useTransportStoresongbird-transportdaw.session.json
useMixerStoresongbird-mixerdaw.state.json
useChatStoresongbird-chatdaw.session.json
useLyriaStoresongbird-lyriadaw.session.json

Persist Flow

React setState → Zustand persist middleware → juceBridge.setItem(storeName, json)
  → C++ updateState native function → handleStateUpdate()

Hydration Flow (Load)

C++ loadStateCache() → reads daw.state.json / daw.session.json
  → Zustand persist getItem → juceBridge.getItem(storeName) → C++ loadState
  → returns cached JSON → Store hydrates synchronously
  → onRehydrateStorage callback → counter++ → all 4 done → reactReady()

State Sync (C++ Side)

handleStateUpdate(storeName, jsonValue) — StateSync.cpp

Central handler for all React → C++ state updates.
  1. Echo suppression: Compares incoming JSON against stateCache[storeName]. If identical, returns immediately (prevents feedback loops).
  2. Mixer commits: If storeName == "songbird-mixer" AND isLoadFinished AND NOT undoRedoInProgress:
    • Diffs old vs new state via describeMixerChange() for descriptive commit message
    • Saves state + edit state to disk
    • Commits via commitAndNotify()
    • Normalizes cached JSON (round-trips through JUCE parser to ensure future echo comparisons match)
  3. Session debounce: Non-mixer stores trigger a 500ms timer → saveSessionState()

describeMixerChange(oldJson, newJson) — StateSync.cpp

Generates human-readable commit messages by diffing mixer state:
  • 'drums' vol 80->65 — volume changes (old->new)
  • 'bass' muted / 'chords' solo on — toggle changes
  • 'drums' send0 — send level changes
  • mixerOpen -> true — panel state changes
  • Mixer update — fallback when only untracked fields changed

TrackWatcher — Engine → React

Each audio track has a TrackStateWatcher that listens to Tracktion Engine’s ValueTree for property changes (volume, pan, mute, solo).
Engine ValueTree change → TrackStateWatcher (100ms debounce + delta threshold)
  → pushToReact() → emitEvent("trackMixerUpdate", {trackIndex, volume, pan, muted, solo})
  → React trackMixerUpdate listener → useMixerStore.setState()
  → Zustand persist → handleStateUpdate() → echo suppressed (same JSON)
Key details:
  • Values are rounded to integers before pushing (volume: 0-127, pan: -64..63)
  • A suppressMixerEcho flag prevents feedback when applyMixerState() is changing engine state from React
  • Delta thresholds (0.5% volume, 2% pan) prevent noise

Real-Time Events (Engine → React)

High-frequency telemetry data (audio levels, transport position, stereo analysis, CPU stats) bypasses the standard JSON state sync to minimize IPC and React overhead.
  1. Batched C++ to JS: All real-time telemetry is grouped into a single JSON payload and emitted at ~30Hz under the rtFrame event.
  2. Direct-DOM Buffer (rtBuffer): Data is written directly to a shared mutable object (getRtBuffer()). This avoids React re-renders completely.
  3. Ballistic Smoothing: A single requestAnimationFrame loop handles smoothing (e.g. meter decay) and notifies direct subscribers (canvas, plain DOM nodes).
  4. Zustand Throttling: The full Zustand store (useMeterStore) is only updated every N frames (e.g. ~20Hz) for React components that need reactive bindings, avoiding main-thread blocking.
  5. Transport Sync: Playhead position is synced at 60Hz into useTransportStore non-reactively via .setState().

Loading Sequence

1. SongbirdEditor constructor
2. uiReady() from React → startBackgroundLoading()
3. scanForPlugins()
4. loadBirdFile(currentBirdFile)
   - Parse .bird → populate Tracktion Edit
   - Load plugins (async initialization)
   - Build mixer state from C++ tracks
   - Apply mixer state to engine
5. playbackInfo.setEdit()
6. Push trackState to React
7. saveStateCache() → set pendingProjectLoadCommit = true → startTimer(1000ms)
8. [Meanwhile] React hydrates stores → reactReady() → sets reactHydrated = true
9. Plugins settle (audioProcessorParameterChanged stops firing)
10. Timer fires:
    - If !reactHydrated → retry in 200ms
    - saveSessionState() + saveEditState()
    - commitAndNotify("Project loaded")
    - isLoadFinished = true ← COMMITS NOW ENABLED

Flags

FlagSet WhenPurpose
isLoadFinished”Project loaded” commit firesGates ALL mixer commits
pendingProjectLoadCommitAfter engine init, before timerDefers commit until plugins settle
reactHydratedReact calls reactReady()Ensures React stores are ready before enabling commits
undoRedoInProgressDuring undo/redo, cleared after 200msBlocks echo commits during state restoration

Undo/Redo System — ProjectState.cpp

Uses libgit2 (in-process, zero fork) for git operations.

Branch Structure

  • refs/heads/main — current position (HEAD), moves on undo/redo
  • refs/redo-tip — created on first undo, points to the “newest” undone commit

Operations

Commit (commitAndNotify):
  1. Check hasUncommittedChanges() — skip if nothing changed
  2. Delete refs/redo-tip (new change invalidates redo)
  3. Create commit on main
  4. Emit historyChanged to React
Undo (projectState.undo()):
  1. Block if HEAD message contains “Project loaded” or “Initial project state”
  2. If no refs/redo-tip, create it pointing to current HEAD
  3. Get HEAD’s parent commit
  4. Diff HEAD vs parent → get changed files
  5. Restore working directory from parent
  6. Move refs/heads/main to parent
Redo (projectState.redo()):
  1. Look up refs/redo-tip — if absent, nothing to redo
  2. Walk backward from redo-tip to find the child of current HEAD
  3. Diff HEAD vs child → get changed files
  4. Restore working directory from child
  5. Move refs/heads/main to child
  6. If HEAD now equals redo-tip, delete the ref

Undo/Redo in the Editor

undoProject() / redoProject() in SongbirdEditor.cpp:
  1. Set undoRedoInProgress = true
  2. Flush pending state (saveStateCache + saveEditState)
  3. Call projectState.undo() / redo()
  4. Reload changed files:
    • .bird → re-parse and populate edit
    • .edit.json → restore plugin state
    • .state.json → reload mixer and apply to engine + push to React
  5. Emit historyChanged to update UI
  6. Clear undoRedoInProgress after 200ms delay (blocks React persist echoes)

Commit Sources

Every commit message is tagged with a source:
TagSourceExample
[auto]System[auto] Project loaded
[mixer]Fader/knob change[mixer] 'drums' vol 80->65
[LLM]AI copilot[LLM] Pre-LLM state
[user]Manual save/revert[user] Reverted last AI change

Echo Prevention (Critical)

Multiple mechanisms prevent feedback loops:
  1. String comparison in handleStateUpdate() — skip if incoming JSON == cached JSON
  2. JSON normalization after commit — round-trip through JSON::parse + JSON::toString so React echo string matches
  3. undoRedoInProgress flag — blocks mixer commits during undo/redo (cleared after 200ms)
  4. isLoadFinished gate — blocks ALL mixer commits until project is fully loaded
  5. hasUncommittedChanges() guard in ProjectState::commit() — skip if git working tree is clean
  6. suppressMixerEcho flag — blocks TrackWatcher from pushing to React when applyMixerState() is running
  7. Integer rounding in setVolume / setPan — prevents float precision diffs (e.g., 69 vs 69.28)

History Panel — React

HistoryPanel.tsx displays live git history in git log --oneline format.
  • Fetches via getHistory native function (reads git via libgit2 revwalk)
  • Auto-refreshes on historyChanged events (emitted by commitAndNotify() and after undo/redo)
  • Expandable toggle at bottom of app

Key Files

FileRole
StateSync.cpphandleStateUpdate(), describeMixerChange(), echo suppression
SongbirdEditor.cppLoading sequence, undo/redo orchestration, commitAndNotify()
SongbirdEditor.hFlag declarations, method signatures
ProjectState.cppGit operations: commit, undo, redo, history
ProjectState.hPublic API for git state management
SessionState.cppEphemeral session state management
WebViewBridge.cppNative functions exposed to React (getHistory, reactReady, etc.)
TrackStateWatcher.hEngine → React volume/pan/mute/solo sync
react_ui/src/data/store.tsZustand stores, hydration tracking, event listeners
react_ui/src/data/meters.tsReal-time batched event store, RT Buffer, Ballistic smoothing
react_ui/src/data/slices/mixer.tsMixer state actions (setVolume, setPan with rounding)
react_ui/src/components/HistoryPanel.tsxGit log UI