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:| File | Tracked | Purpose |
|---|---|---|
daw.bird | ✅ Git | Composition — notes, arrangement, structure |
daw.state.json | ✅ Git | UI state — mixer (volumes, pans, mutes, solos, sends) |
daw.edit.json | ✅ Git | Plugin state — VST3/AU presets as structured JSON |
daw.session.json | ❌ Gitignored | Session state — transport, chat, Lyria config |
daw.edit.xml | ❌ Gitignored | Tracktion Engine native edit XML |
Zustand Stores (React)
Four persisted stores, each synced to C++ via thejuceBridge storage adapter:
| Store | ID | Persisted To |
|---|---|---|
useTransportStore | songbird-transport | daw.session.json |
useMixerStore | songbird-mixer | daw.state.json |
useChatStore | songbird-chat | daw.session.json |
useLyriaStore | songbird-lyria | daw.session.json |
Persist Flow
Hydration Flow (Load)
State Sync (C++ Side)
handleStateUpdate(storeName, jsonValue) — StateSync.cpp
Central handler for all React → C++ state updates.
- Echo suppression: Compares incoming JSON against
stateCache[storeName]. If identical, returns immediately (prevents feedback loops). - Mixer commits: If
storeName == "songbird-mixer"ANDisLoadFinishedAND NOTundoRedoInProgress:- 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)
- Diffs old vs new state via
- 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 changesmixerOpen -> true— panel state changesMixer update— fallback when only untracked fields changed
TrackWatcher — Engine → React
Each audio track has aTrackStateWatcher that listens to Tracktion Engine’s ValueTree for property changes (volume, pan, mute, solo).
- Values are rounded to integers before pushing (volume: 0-127, pan: -64..63)
- A
suppressMixerEchoflag prevents feedback whenapplyMixerState()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.- Batched C++ to JS: All real-time telemetry is grouped into a single JSON payload and emitted at ~30Hz under the
rtFrameevent. - Direct-DOM Buffer (
rtBuffer): Data is written directly to a shared mutable object (getRtBuffer()). This avoids React re-renders completely. - Ballistic Smoothing: A single
requestAnimationFrameloop handles smoothing (e.g. meter decay) and notifies direct subscribers (canvas, plain DOM nodes). - 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. - Transport Sync: Playhead position is synced at 60Hz into
useTransportStorenon-reactively via.setState().
Loading Sequence
Flags
| Flag | Set When | Purpose |
|---|---|---|
isLoadFinished | ”Project loaded” commit fires | Gates ALL mixer commits |
pendingProjectLoadCommit | After engine init, before timer | Defers commit until plugins settle |
reactHydrated | React calls reactReady() | Ensures React stores are ready before enabling commits |
undoRedoInProgress | During undo/redo, cleared after 200ms | Blocks 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/redorefs/redo-tip— created on first undo, points to the “newest” undone commit
Operations
Commit (commitAndNotify):
- Check
hasUncommittedChanges()— skip if nothing changed - Delete
refs/redo-tip(new change invalidates redo) - Create commit on
main - Emit
historyChangedto React
projectState.undo()):
- Block if HEAD message contains “Project loaded” or “Initial project state”
- If no
refs/redo-tip, create it pointing to current HEAD - Get HEAD’s parent commit
- Diff HEAD vs parent → get changed files
- Restore working directory from parent
- Move
refs/heads/mainto parent
projectState.redo()):
- Look up
refs/redo-tip— if absent, nothing to redo - Walk backward from redo-tip to find the child of current HEAD
- Diff HEAD vs child → get changed files
- Restore working directory from child
- Move
refs/heads/mainto child - If HEAD now equals redo-tip, delete the ref
Undo/Redo in the Editor
undoProject() / redoProject() in SongbirdEditor.cpp:
- Set
undoRedoInProgress = true - Flush pending state (saveStateCache + saveEditState)
- Call
projectState.undo()/redo() - 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
- Emit
historyChangedto update UI - Clear
undoRedoInProgressafter 200ms delay (blocks React persist echoes)
Commit Sources
Every commit message is tagged with a source:| Tag | Source | Example |
|---|---|---|
[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:- String comparison in
handleStateUpdate()— skip if incoming JSON == cached JSON - JSON normalization after commit — round-trip through
JSON::parse+JSON::toStringso React echo string matches undoRedoInProgressflag — blocks mixer commits during undo/redo (cleared after 200ms)isLoadFinishedgate — blocks ALL mixer commits until project is fully loadedhasUncommittedChanges()guard inProjectState::commit()— skip if git working tree is cleansuppressMixerEchoflag — blocks TrackWatcher from pushing to React whenapplyMixerState()is running- 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
getHistorynative function (reads git via libgit2 revwalk) - Auto-refreshes on
historyChangedevents (emitted bycommitAndNotify()and after undo/redo) - Expandable toggle at bottom of app
Key Files
| File | Role |
|---|---|
StateSync.cpp | handleStateUpdate(), describeMixerChange(), echo suppression |
SongbirdEditor.cpp | Loading sequence, undo/redo orchestration, commitAndNotify() |
SongbirdEditor.h | Flag declarations, method signatures |
ProjectState.cpp | Git operations: commit, undo, redo, history |
ProjectState.h | Public API for git state management |
SessionState.cpp | Ephemeral session state management |
WebViewBridge.cpp | Native functions exposed to React (getHistory, reactReady, etc.) |
TrackStateWatcher.h | Engine → React volume/pan/mute/solo sync |
react_ui/src/data/store.ts | Zustand stores, hydration tracking, event listeners |
react_ui/src/data/meters.ts | Real-time batched event store, RT Buffer, Ballistic smoothing |
react_ui/src/data/slices/mixer.ts | Mixer state actions (setVolume, setPan with rounding) |
react_ui/src/components/HistoryPanel.tsx | Git log UI |