Skip to content

WebSocket AST Architecture

This document describes the architecture of VimAbl's WebSocket-based AST (Abstract Syntax Tree) visualization system, which provides real-time synchronization between Ableton Live and a web-based tree viewer.

Overview

The WebSocket AST system enables live visualization of your Ableton Live project structure with sub-100ms latency. Changes made in Live are instantly reflected in the web UI through a combination of UDP event streaming and WebSocket broadcasting.

System Architecture

┌─────────────────────┐
│   Ableton Live      │
│   Remote Script     │
└──────────┬──────────┘
           │ UDP (OSC)
           │ port 9002
┌─────────────────────┐
│   UDP Listener      │
│   (Python)          │
└──────────┬──────────┘
┌─────────────────────┐      ┌──────────────────┐
│   AST Server        │◄─────┤ XML File Watcher │
│   (Python)          │      │ (Watchdog)       │
└──────────┬──────────┘      └──────────────────┘
           │ WebSocket
           │ port 8765
┌─────────────────────┐
│   Web UI            │
│   (Svelte 5)        │
└─────────────────────┘

Core Components

1. UDP Listener (src/udp_listener/listener.py)

Purpose: Receives real-time OSC events from Ableton Live's Remote Script

Architecture (Phase 5j - Queue-Based):

UDP Socket (Port 9002)
Receive Loop (non-blocking, async)
Parse OSC Message
Sequence Tracking (deduplication)
Event Queue [put_nowait]
┌─────────────────────────────┐
│  asyncio.Queue (1000 events)│
└─────────────────────────────┘
Event Processor Task (concurrent)
Event Callback (WebSocket broadcast)

Key Features: - Non-blocking reception: UDP receives never block, even during slow WebSocket broadcasts - Event queue: 1000-event asyncio.Queue decouples reception from processing - Concurrent tasks: Receiver and processor run in parallel - Zero packet loss: Tested with 55+ events in <1ms bursts - Sequence tracking: Gap detection with monotonic sequence numbers - Statistics tracking: Packets received, processed, dropped, queue depth

Queue Benefits: - Prevents UDP buffer overflow during rapid event bursts (e.g., scene reordering) - WebSocket broadcast latency doesn't affect UDP reception - Graceful overload handling (queue fills → drop at source with logging)

Message Format:

/live/seq <seq_num:int> <timestamp:float> <event_path:string> <args...>

Example:

/live/seq 123 1234567890.123 /live/track/renamed 0 "My Track"

Statistics Available:

stats = listener.get_stats()
# Returns:
{
    "packets_received": 1250,
    "packets_processed": 1250,
    "packets_dropped": 0,
    "parse_errors": 0,
    "queue_size": 3,
    "queue_max": 12,
    "sequence": {
        "total_received": 1250,
        "duplicates": 2,
        "gaps": 0,
        "gap_size_total": 0
    }
}

2. AST Server (src/server/api.py)

Purpose: Central server that manages the AST, processes events, and broadcasts updates.

Core Class: ASTServer The main entry point that orchestrates AST management. It delegates specific functionality to specialized services and handlers.

  • Services:
    • QueryService: Handles AST queries (find_node_by_id, get_ast_json, diff_with_file).
    • ProjectService: Handles project loading and parsing (load_project).
  • Event Routing: Uses a registry (_build_event_handler_registry) to route OSC paths to specialized handlers.
  • Debouncing: Uses DebouncedBroadcaster for high-frequency events like parameters and tempo.

Event Handlers (src/server/handlers/): Logic for processing specific event types is refactored into dedicated classes: * TrackEventHandler: Handles track renaming and state changes (mute, arm, volume). * DeviceEventHandler: Handles device addition, deletion, and parameter updates. * SceneEventHandler: Handles scene operations (add, remove, rename, reorder). * ClipSlotEventHandler: Handles clip slot creation and status updates. * TransportEventHandler: Handles global transport state (play, tempo).

AST Helpers (src/server/ast_helpers.py): A suite of utility classes for AST manipulation and maintenance: * ASTNavigator: Static methods to find tracks, scenes, and traverse the tree. * HashManager: Recomputes node hashes and propagates changes up the tree. * DiffGenerator: Creates standardized diff objects (added, removed, modified, state_changed). * SceneIndexManager: Handles complex index shifting for scenes and clip slots during insertion/removal. * ClipSlotManager: Manages clip slot deduplication and ordered insertion by scene_index. * ASTBuilder: Bridges the gap between the raw XML parser and the ASTNode object structure.

Event Handler Map:

Method Event Action
TrackEventHandler.handle_track_renamed /live/track/renamed Update track name, rehash, broadcast diff
TrackEventHandler.handle_track_state /live/track/mute
/live/track/arm
/live/track/volume
Update mixer attributes (no rehash)
DeviceEventHandler.handle_device_added /live/device/added Add device node, rehash, broadcast diff
DeviceEventHandler.handle_device_deleted /live/device/deleted Remove device node, rehash, broadcast diff
SceneEventHandler.handle_scene_added /live/scene/added Add scene, shift indices, broadcast diff
SceneEventHandler.handle_scene_removed /live/scene/removed Remove scene, shift indices, broadcast diff
SceneEventHandler.handle_scene_renamed /live/scene/renamed Update scene name, rehash, broadcast diff
ClipSlotEventHandler.handle_clip_slot_created /live/clip_slot/created Add/update clip slot, rehash, broadcast diff

Structural vs State Changes:

  • Structural changes (track rename, device add/delete, scene add/remove, clip slot creation):
  • Modify node structure or identity
  • Require hash recomputation
  • Broadcast as DIFF_UPDATE messages
  • Trigger visual indicators (green/yellow/red)

  • State changes (mute, volume, parameters):

  • Lightweight attribute updates
  • No hash recomputation needed
  • Broadcast as live_event messages
  • Update silently (no visual indicator)

3. WebSocket Server (src/server/websocket.py)

Purpose: Manages WebSocket connections and message broadcasting

Key Classes:

MessageBroadcaster

Handles fan-out of messages to all connected clients: - Maintains list of active connections - Broadcasts to all clients simultaneously - Handles connection errors gracefully - Logs broadcast activity

ASTWebSocketServer

WebSocket server implementation: - Starts asyncio server on specified host/port - Handles client connections - Sends full AST on connect - Routes incoming messages (future: bi-directional control) - Broadcasts diffs and live events

4. Serializers (src/websocket/serializers.py)

Purpose: Converts AST nodes and diffs into JSON-serializable formats for WebSocket communication, and constructs standardized WebSocket messages.

Key Classes/Functions:

ASTSerializer

Provides static methods for converting AST nodes and diff results into JSON-compatible dictionaries: - serialize_node(node: ASTNode, include_children: bool = True, depth: int = -1): Converts an AST node into a dictionary, optionally including children and controlling serialization depth. - serialize_diff(diff_result: Dict[str, Any]): Converts a raw diff result dictionary into a standardized JSON-compatible diff representation, ensuring all change types are properly formatted. - to_json(data: Dict[str, Any], pretty: bool = False): A utility to convert any dictionary to a JSON string.

Message Creation Functions

Convenience functions for constructing specific WebSocket message types: - create_message(msg_type: str, payload: Dict[str, Any]): General utility to create a message envelope. - create_full_ast_message(root: ASTNode, project_path: Optional[str] = None): Creates a FULL_AST message containing the entire serialized AST. - create_diff_message(diff_result: Dict[str, Any]): Creates a DIFF_UPDATE message with serialized diff changes. - create_error_message(error: str, details: Optional[str] = None): Creates an ERROR message for broadcasting server-side issues. - create_ack_message(request_id: Optional[str] = None): Creates an ACK message to acknowledge client requests.

5. XML File Watcher (src/main.py:XMLFileWatcher)

Purpose: Detect when the user saves the project in Live and reload the AST

Why Needed: - Structural changes (add/delete tracks) aren't fully supported via UDP - UDP event gaps can cause desynchronization - File save provides a reliable "checkpoint" for full sync

How It Works: 1. Uses watchdog library to monitor the XML file 2. Debounces events (1 second) to avoid duplicate reloads 3. On file change: - Load new XML and build fresh AST - Compare with old AST using DiffVisitor - Broadcast diff to all clients 4. If old AST exists, sends incremental diff 5. If first load, sends full AST

Diff Format:

{
    'changes': [
        {
            'type': 'added',  # or 'removed', 'modified'
            'node_id': 'device_123',
            'node_type': 'device',
            'path': 'tracks[3].devices[1]',
            'new_value': {'name': 'Reverb', 'device_type': 'AudioEffect'}
        }
    ],
    'added': ['device_123'],
    'removed': ['device_456'],
    'modified': ['track_0']
}

5. Frontend AST Store (src/web/frontend/src/lib/stores/ast.svelte.ts)

Purpose: Manage AST state in the Svelte UI with Svelte 5 reactivity

State Management:

interface ASTState {
  root: ASTNode | null;
  projectInfo: ProjectInfo | null;
  projectPath: string | null;
  lastSeqNum: number;
  isStale: boolean;
}

// Reactive state using Svelte 5 $state rune
let astState = $state<ASTState>({
  root: null,
  projectInfo: null,
  projectPath: null,
  lastSeqNum: 0,
  isStale: false
});

Key Functions:

updateSequenceNumber(seqNum: number)

Centralized sequence tracking for gap detection:

function updateSequenceNumber(seqNum: number): void {
  if (astState.lastSeqNum > 0 && seqNum > astState.lastSeqNum + 1) {
    const gap = seqNum - astState.lastSeqNum - 1;
    console.warn(`Gap detected: ${gap} events missed`);
    astState.isStale = true;
  }
  astState.lastSeqNum = seqNum;
}

applyDiff(diffPayload: any)

Apply structured diff from XML file saves:

function applyDiff(diffPayload: any): void {
  const changes = diffPayload.changes || diffPayload;

  for (const change of changes) {
    if (change.type === 'added') {
      // Create new node, mark as added, set 5s timer
      (newNode as any)._changeType = 'added';
      setTimeout(() => delete (newNode as any)._changeType, 5000);
    } else if (change.type === 'removed') {
      // Mark for removal, animate, delete after 500ms
      (node as any)._changeType = 'removed';
      setTimeout(() => { /* actually remove */ }, 500);
    } else if (change.type === 'modified') {
      // Update attributes, mark as modified
      (node as any)._changeType = 'modified';
      setTimeout(() => delete (node as any)._changeType, 5000);
    } else if (change.type === 'state_changed') {
      // Lightweight update, no visual indicator
    }
  }

  // Trigger Svelte 5 reactivity
  astState.root = astState.root;
}

applyLiveEvent(eventPath: string, args: any[], seqNum: number)

Apply real-time UDP events via AST Updater:

function applyLiveEvent(eventPath: string, args: any[], seqNum: number): void {
  // Update sequence tracking
  if (seqNum > 0) {
    updateSequenceNumber(seqNum);
  }

  // Apply event to AST
  const success = astUpdater.updateFromLiveEvent(astState.root, eventPath, args);

  if (success) {
    // Throttle high-frequency events
    const isHighFrequency =
      eventPath === '/live/track/volume' ||
      eventPath === '/live/transport/tempo' ||
      eventPath === '/live/device/param';

    if (isHighFrequency) {
      // Only trigger reactivity every 100ms
      throttleTimer = window.setTimeout(() => {
        astState.root = astState.root;
      }, 100);
    } else {
      // Immediate update for structural changes
      astState.root = astState.root;
    }
  }
}

6. AST Node Types (src/ast/node.py)

Purpose: Defines the data model for the Ableton Live project's Abstract Syntax Tree (AST). Each node represents a distinct entity within the project (e.g., track, device, scene).

Key Constructs:

NodeType Enum

An enumeration defining all possible types of AST nodes. This provides a standardized way to identify node roles within the tree. * Examples: PROJECT, TRACK, DEVICE, CLIP_SLOT, SCENE, MIXER, CLIP, FILE_REF, AUTOMATION, PARAMETER.

ASTNode (Base Class)

The foundational class for all nodes in the AST. It provides common properties and methods for tree management: * node_type: The specific type of the node (from NodeType enum). * id: A unique identifier for the node (used for diffing and referencing). * parent: Reference to the parent node in the AST. * children: A list of child ASTNode objects. * attributes: A dictionary to store various properties specific to the node type (e.g., name, index, volume). * hash: A cryptographic hash representing the node's current state (used for efficient change detection). * add_child(child): Adds a child node and sets its parent. * remove_child(child): Removes a child node.

Concrete Node Types

Specialized dataclass implementations inheriting from ASTNode, each representing a specific Ableton Live entity:

  • ProjectNode: The root of the AST, representing the entire Ableton Live project. Key attributes: version, creator.
  • TrackNode: Represents an audio, MIDI, return, or master track. Key attributes: name, index, color, is_muted, is_soloed.
  • DeviceNode: Represents an instrument or effect device on a track. Key attributes: name, device_type, is_enabled.
  • ClipSlotNode: Represents a slot in the Session View grid where a clip can reside. Key attributes: track_index, scene_index, has_clip, is_playing, is_triggered.
  • ClipNode: Represents an actual MIDI or audio clip. Key attributes: name, clip_type, start_time, end_time, is_looped.
  • FileRefNode: Represents a reference to an external file (e.g., samples). Key attributes: name, path, hash_val, ref_type.
  • SceneNode: Represents a scene (horizontal row) in the Session View. Key attributes: name, index, tempo.
  • MixerNode: Represents the mixer section of a track. Key attributes: volume, pan, is_muted, is_soloed.
  • ParameterNode: Represents an automatable parameter of a device. Key attributes: name, value, min, max, is_automated.

7. AST Updater (src/web/frontend/src/lib/stores/ast-updater.ts)

Purpose: Map UDP events to AST node mutations

Key Methods:

class ASTUpdater {
  updateFromLiveEvent(ast: ASTNode | null, eventPath: string, args: any[]): boolean {
    switch (eventPath) {
      case '/live/track/renamed':
        return this.updateTrackName(ast, args[0], args[1]);
      case '/live/track/mute':
        return this.updateTrackMute(ast, args[0], args[1]);
      case '/live/track/color':
        return this.updateTrackColor(ast, args[0], args[1]);
      case '/live/device/added':
        return this.addDevice(ast, args[0], args[1], args[2]);
      case '/live/device/deleted':
        return this.removeDevice(ast, args[0], args[1]);
      // ... more handlers
    }
  }

  private updateTrackName(ast: ASTNode, trackIndex: number, newName: string): boolean {
    const track = this.findTrack(ast, trackIndex);
    track.attributes.name = newName;

    // Mark as modified for visual indicator (yellow)
    (track as any)._changeType = 'modified';
    setTimeout(() => delete (track as any)._changeType, 5000);

    return true;
  }

  private addDevice(ast: ASTNode, trackIndex: number, deviceIndex: number, deviceName: string): boolean {
    const track = this.findTrack(ast, trackIndex);
    const newDevice: DeviceNode = { /* ... */ };
    track.children.splice(deviceIndex, 0, newDevice);

    // Mark as added for visual indicator (green)
    (newDevice as any)._changeType = 'added';
    setTimeout(() => delete (newDevice as any)._changeType, 5000);

    return true;
  }

  private removeDevice(ast: ASTNode, trackIndex: number, deviceIndex: number): boolean {
    const device = /* find device */;

    // Mark as removed for visual indicator (red)
    (device as any)._changeType = 'removed';

    // Remove after animation delay
    setTimeout(() => {
      track.children = track.children.filter(c => c.id !== device.id);
    }, 500);

    return true;
  }
}

Visual Change Markers:

The updater sets temporary _changeType markers on modified nodes: - 'added': Green highlight with slide-in animation for 1 second - 'modified': Yellow highlight with pulse animation for 1 second (suppressed if track is selected) - 'removed': Red highlight with fade-out animation, then delete after 1 second

Priority: Blue selection highlighting takes precedence over yellow modification highlights to avoid visual confusion when editing the currently selected track.

7. TreeNode Component (src/web/frontend/src/lib/components/TreeNode.svelte)

Purpose: Recursive component that renders AST nodes with Svelte 5 reactivity

Key Features:

Svelte 5 Runes

// Props using $props()
let { node, depth = 0 }: { node: ASTNode; depth?: number } = $props();

// Reactive state
let expanded = $state(depth < 2);
let isFlashing = $state(false);

// Derived values
let hasChildren = $derived(node.children && node.children.length > 0);
let changeType = $derived((node as any)._changeType || null);
let isSelectedTrack = $derived(
  node.node_type === 'track' &&
  cursorStore.selectedTrackIdx !== null &&
  node.attributes?.index === cursorStore.selectedTrackIdx
);

// Side effects
$effect(() => {
  if (isSelectedTrack && nodeHeaderElement) {
    nodeHeaderElement.scrollIntoView({
      behavior: 'smooth',
      block: 'center'
    });
  }
});

Flash Animation (First-Touch-Only)

let previousAttributes = $state(JSON.stringify(node.attributes));
let hasFlashedForCurrentSequence = $state(false);
let changeTimer: number | null = null;

$effect(() => {
  const currentAttributes = JSON.stringify(node.attributes);
  if (previousAttributes !== currentAttributes) {
    // Flash only if we haven't flashed yet for this sequence
    if (!hasFlashedForCurrentSequence) {
      isFlashing = true;
      setTimeout(() => { isFlashing = false; }, 600);
      hasFlashedForCurrentSequence = true;
    }

    // Reset after 1 second of inactivity
    if (changeTimer !== null) clearTimeout(changeTimer);
    changeTimer = window.setTimeout(() => {
      hasFlashedForCurrentSequence = false;
    }, 1000);
  }
  previousAttributes = currentAttributes;
});

Visual Highlighting

<div class="node-header"
     class:flashing={isFlashing}
     class:selected-track={isSelectedTrack}
     class:highlighted-slot={isHighlightedClipSlot}
     class:node-added={changeType === 'added'}
     class:node-modified={changeType === 'modified'}
     class:node-removed={changeType === 'removed'}
     style={trackColor ? `border-left: 4px solid ${trackColor}` : ''}>
  <!-- Node content -->
</div>

CSS Animations:

/* Flash animation for attribute changes */
.node-header.flashing {
  animation: flash 0.6s ease-out;
}

@keyframes flash {
  0% { background-color: rgba(59, 130, 246, 0.4); transform: scale(1.02); }
  50% { background-color: rgba(59, 130, 246, 0.2); }
  100% { background-color: transparent; transform: scale(1); }
}

/* Color-coded change indicators */
.node-header.node-added {
  background-color: rgba(34, 197, 94, 0.15) !important;
  border-left: 3px solid #22c55e;  /* Green */
  animation: slideIn 0.5s ease-out;
}

.node-header.node-modified {
  background-color: rgba(251, 191, 36, 0.15) !important;
  border-left: 3px solid #fbbf24;  /* Yellow */
  animation: pulse 0.6s ease-out;
}

.node-header.node-removed {
  background-color: rgba(239, 68, 68, 0.15) !important;
  border-left: 3px solid #ef4444;  /* Red */
  text-decoration: line-through;
  opacity: 0.6;
  animation: fadeOut 0.5s ease-out;
}

Data Flow Scenarios

Scenario 1: User Renames Track in Live

1. User renames "Audio 1" → "Vocals" in Live
2. TrackObserver fires _on_name_changed()
3. Remote Script sends: /live/seq 123 <time> /live/track/renamed 0 "Vocals"
4. UDP Listener receives packet
5. Main.py udp_event_callback invoked
6. ASTServer.process_live_event() called
7. _handle_track_renamed() executes:
   - Finds track by index (0)
   - Updates track.attributes['name'] = "Vocals"
   - Recomputes hashes (track → project)
   - Generates diff: {type: 'modified', node_id: 'track_0', ...}
   - Broadcasts DIFF_UPDATE to WebSocket clients
8. Frontend receives DIFF_UPDATE message
9. astStore.applyDiff() called
10. Finds track node by ID
11. Updates attributes
12. Sets _changeType = 'modified' (5s timer)
13. Triggers reactivity: astState.root = astState.root
14. TreeView re-renders
15. TreeNode detects changeType = 'modified'
16. Yellow highlight + pulse animation for 5 seconds

Latency: ~50-100ms (UDP → visual update)

Scenario 2: User Adds Device to Track

1. User drags "Reverb" onto track 0
2. DeviceObserver fires _on_device_added()
3. Remote Script sends: /live/seq 124 <time> /live/device/added 0 1 "Reverb"
4-7. [Same UDP → AST Server flow]
8. _handle_device_added() executes:
   - Finds track 0
   - Creates new DeviceNode
   - Inserts at index 1
   - Rehashes track → project
   - Broadcasts DIFF_UPDATE
9-11. [Same frontend flow]
12. astStore.applyDiff() creates new device node
13. Sets _changeType = 'added' (5s timer)
14. Triggers reactivity
15. TreeView re-renders with new device
16. Green highlight + slideIn animation for 5 seconds

Scenario 3: User Saves Project (XML Reload)

1. User presses Cmd+S in Live
2. Live writes new XML file to disk
3. XMLFileWatcher detects file modification (watchdog)
4. Debounces for 1 second
5. _reload_and_broadcast() executes:
   - Stores old AST reference
   - Loads new XML with load_ableton_xml()
   - Builds new AST with build_ast()
   - Compares old vs new with DiffVisitor
   - Generates structured diff
   - Broadcasts DIFF_UPDATE
6-13. [Same frontend flow as Scenario 1]

Use Cases: - User added/deleted tracks (not supported via UDP) - UDP event gaps (> 5 events missed) - Major structural changes

Scenario 4: User Moves Volume Fader (High-Frequency)

1. User drags track 0 volume fader
2. MixerObserver fires _on_volume_changed() 10-20x/second
3. Remote Script sends: /live/seq 125-145 ... /live/track/volume 0 <value>
4-6. [UDP flow]
7. ASTServer.process_live_event():
   - Detects /live/device/param or /live/track/volume
   - Returns {broadcast_only: True} (no AST update)
8. Main.py broadcasts live_event message directly
9. Frontend receives live_event messages
10. astStore.updateSequenceNumber() for all events
11. If volume event: astStore.applyLiveEvent():
    - Detects high-frequency event
    - Sets pendingUpdate = true
    - Throttles to 100ms intervals
    - Only triggers reactivity every 100ms
12. TreeNode updates volume value
13. NO flash animation (silent update)

Throttling Benefits: - Reduces re-renders from 200/sec to 10/sec - Maintains 60 FPS - Values still update smoothly

Scenario 5: Gap Detection and Recovery

1. Network congestion causes UDP packet loss
2. Events 150-155 dropped (6 events)
3. Frontend receives event #156
4. astStore.updateSequenceNumber(156):
   - Detects gap: 156 - 149 - 1 = 6 events missed
   - Sets astState.isStale = true
   - Logs warning
5. ConnectionStatus component shows "⚠️ Stale"
6. User sees warning: "Save project to resync"
7. User presses Cmd+S in Live
8. XMLFileWatcher triggers full reload
9. Fresh AST sent via DIFF_UPDATE
10. Frontend receives and applies diff
11. astState.isStale = false
12. ConnectionStatus shows "🟢 Connected"

Gap Threshold: 5 events Recovery: Manual save required

Scenario 6: User Adds Scene (Server-Side AST Update)

1. User presses Cmd+I in Live (Insert Scene)
2. SceneObserver fires _on_scene_added()
3. Remote Script sends: /live/seq 160 <time> /live/scene/added 2 "Scene 3"
4. UDP Listener receives packet
5. ASTServer.process_live_event() called
6. _handle_scene_added() executes:
   - Uses SceneIndexManager to shift indices for all scenes > 2
   - Uses SceneIndexManager to shift scene_index for all clip slots > 2
   - Creates new SceneNode at index 2
   - Inserts SceneNode into AST children
   - Recomputes hashes
   - Generates complex diff (1 added scene, N modified scenes/slots)
   - Broadcasts DIFF_UPDATE
7. Frontend receives DIFF_UPDATE
8. astStore.applyDiff() executes:
   - Updates indices of existing nodes (no animation)
   - Inserts new scene node (Green highlight + slideIn)
9. TreeView re-renders with new scene structure

Latency: ~50-100ms Consistency: Server AST remains authoritative source of truth

Performance Optimizations

1. Throttling High-Frequency Events

Problem: Volume/parameter changes send 10-20 events/second, causing excessive re-renders

Solution:

// Throttle to 100ms intervals
if (isHighFrequency) {
  pendingUpdate = true;
  if (throttleTimer === null) {
    throttleTimer = window.setTimeout(() => {
      if (pendingUpdate) {
        astState.root = astState.root;  // Trigger reactivity
        pendingUpdate = false;
      }
      throttleTimer = null;
    }, 100);
  }
}

Impact: - Reduces re-renders by 90% - Maintains smooth visual feedback - Prevents UI jank

2. First-Touch-Only Flash Animation

Problem: Continuous parameter changes cause constant flashing (visually overwhelming)

Solution: - First change: Flash immediately (shows where change is happening) - Subsequent changes: Update values silently - Reset after 1 second: Next change will flash again

Benefits: - Clear visual indication of location without distraction - Smooth value updates during continuous adjustments - User-friendly for rapid parameter tweaking

3. Svelte 5 Fine-Grained Reactivity

Advantage: Only components with changed nodes re-render

Mechanism:

// Svelte 5 tracks individual $state and $derived values
let hasChildren = $derived(node.children && node.children.length > 0);
let changeType = $derived((node as any)._changeType || null);

// When changeType changes, only this component re-renders
// Parent and sibling components are unaffected

Impact: - Large projects (100+ tracks) maintain 60 FPS - Minimal CPU usage - Memory efficient

4. Selective Hash Recomputation

Problem: Recomputing entire tree is expensive

Solution: - Structural changes: Rehash modified node + ancestors - State changes: No rehashing (lightweight updates)

Example:

# Track rename: rehash track → project
track_node.attributes['name'] = new_name
hash_tree(track_node)
self._recompute_parent_hashes(track_node)

# Track mute: no rehashing
mixer.attributes['is_muted'] = is_muted
# Broadcast state_changed event

5. Minimal Diff Broadcasting

Problem: Sending full AST on every change is wasteful

Solution: Send only changed nodes

diff_result = {
    'changes': [{
        'type': 'modified',
        'node_id': track_node.node_id,
        'path': f"tracks[{track_idx}]",
        'old_value': {'name': old_name},
        'new_value': {'name': new_name}
    }],
    'modified': [track_node.node_id]
}

Impact: - 100x smaller messages - Lower network bandwidth - Faster parsing

Sequence Number Tracking

Purpose

Detect when UDP events are dropped due to network issues

Implementation

Backend (src/main.py):

last_seq_num = [0]
gap_threshold = 5

async def udp_event_callback(event_path, args, seq_num, timestamp):
    if last_seq_num[0] > 0:
        gap = seq_num - last_seq_num[0] - 1
        if gap > 0:
            print(f"[UDP] Gap detected: {gap} events")
            if gap >= gap_threshold:
                # Broadcast error, mark AST as stale
                await server.websocket_server.broadcast_error(
                    "UDP event gap detected",
                    f"Missed {gap} events. Save project to resync."
                )
    last_seq_num[0] = seq_num

Frontend (ast.svelte.ts):

function updateSequenceNumber(seqNum: number): void {
  if (astState.lastSeqNum > 0 && seqNum > astState.lastSeqNum + 1) {
    const gap = seqNum - astState.lastSeqNum - 1;
    console.warn(`Gap detected: ${gap} events missed`);
    astState.isStale = true;  // Triggers warning in UI
  }
  astState.lastSeqNum = seqNum;
}

Centralized Tracking

Problem: Cursor events weren't updating sequence numbers, causing false gaps

Solution: Centralize tracking in +page.svelte before routing:

else if (message.type === 'live_event') {
  // Update sequence for ALL events (cursor + AST)
  astStore.updateSequenceNumber(liveEvent.payload.seq_num);

  if (eventPath.startsWith('/live/cursor/')) {
    cursorStore.applyCursorEvent(eventPath, args);
  } else {
    astStore.applyLiveEvent(eventPath, args, 0);  // 0 = skip duplicate tracking
  }
}

Error Handling

UDP Packet Loss

  • Detection: Sequence number gaps
  • Notification: WebSocket ERROR message + stale flag
  • Recovery: Save project → XML reload → full sync

WebSocket Disconnection

  • Detection: Connection status in frontend
  • Notification: Red "Disconnected" indicator
  • Recovery: Automatic reconnection attempts

AST Parsing Errors

  • Detection: Exception in load_ableton_xml()
  • Notification: ERROR message to clients
  • Recovery: Keep old AST, wait for next save

Node Not Found

  • Detection: find_track_by_index() returns None
  • Action: Log warning, return early, no broadcast
  • Impact: Event is skipped, no partial updates

Future Enhancements

Bi-Directional Control

Allow UI to send commands back to Live:

// Frontend
function muteTrack(trackIndex: number) {
  websocket.send({
    type: 'command',
    payload: {
      command: 'mute_track',
      args: [trackIndex, true]
    }
  });
}

// Backend
if message['type'] == 'command':
    command = message['payload']['command']
    args = message['payload']['args']
    tcp_client.send_command(command, args)

Structural Change Support

Implement full add/delete support without XML reload:

async def _handle_track_added(self, args, seq_num):
    track_idx = args[0]
    track_name = args[1]

    # Create new TrackNode
    new_track = TrackNode(...)

    # Insert at correct index
    self.current_ast.children.insert(track_idx, new_track)

    # Update indices for subsequent tracks
    for i, track in enumerate(self.current_ast.children):
        if track.node_type == NodeType.TRACK:
            track.attributes['index'] = i

    # Rehash
    hash_tree(new_track)
    self._recompute_parent_hashes(new_track)

    # Broadcast
    await self.websocket_server.broadcast_diff({
        'changes': [{'type': 'added', ...}]
    })

Smart Debouncing

Group rapid changes into batches:

// Collect changes over 50ms window
let changeBuffer = [];
let debounceTimer = null;

function applyChange(change) {
  changeBuffer.push(change);

  if (debounceTimer) clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    applyBatchedChanges(changeBuffer);
    changeBuffer = [];
  }, 50);
}