Oscilla Client-Side Persistence & Synchronization

Developer Guide: File/localStorage Storage, WebSocket Sync, and Shared State Patterns

This document explains how Oscilla handles client-side data persistence and multi-client synchronization for four main subsystems:

  1. Annotations — Performer notes, triggers, text overlays (file-based storage)
  2. Audio Objects — Browser-authored audio triggers on the score (file-based storage)
  3. Markers — Timeline markers and navigation points (localStorage)
  4. ControlXY — XY pad presets, sequences, scenes, launcher configurations (localStorage)

Table of Contents

  1. Architecture Overview
  2. Storage Pattern
  3. Annotations System
  4. Markers System
  5. ControlXY System
  6. WebSocket Synchronization
  7. Import/Export
  8. Migration & Versioning
  9. Debugging

Architecture Overview

Design Principles

┌──────────────────────────────────────────────────────────────────────────────────┐
│                           CLIENT BROWSER                                          │
│                                                                                   │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ Annotations  │  │Audio Objects │  │   Markers    │  │  ControlXY   │          │
│  │ (shared.js)  │  │(audioObj...js│  │ (markers.js) │  │ (Shared.js)  │          │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘          │
│         │                 │                 │                 │                   │
│         │                 │                 ▼                 ▼                   │
│         │                 │        ┌─────────────────────────────────────┐       │
│         │                 │        │            localStorage             │       │
│         │                 │        │ ┌────────────────┐ ┌────────────────┐│      │
│         │                 │        │ │oscilla_mark...│ │oscilla_ctrl...││      │
│         │                 │        │ └────────────────┘ └────────────────┘│      │
│         │                 │        └─────────────────────────────────────┘       │
│         │                 │                 │                 │                   │
│         │ REST API        │ REST API        └─────────────────┘                   │
│         │ (annotations)   │ (audio-objects)        │                              │
│         │                 │                 WebSocket (real-time sync)            │
│         │                 │                        │                              │
└─────────┼─────────────────┼────────────────────────┼──────────────────────────────┘
          │                 │                        │
          ▼                 ▼                        ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│                           SERVER (server.js)                                      │
│                                                                                   │
│  ┌────────────────────────────────────────────────────────────────────────┐      │
│  │  REST API                                                               │      │
│  │  — /api/annotations/:project (GET/POST) → annotations.json              │      │
│  │  — /api/audio-objects/:project (GET/POST) → audio-objects.json          │      │
│  └────────────────────────────────────────────────────────────────────────┘      │
│                                                                                   │
│  ┌────────────────────────────────────────────────────────────────────────┐      │
│  │  WebSocket Hub — broadcasts to other clients + persists to file         │      │
│  │  Message types: annotation_*, audio_object_*, marker_*, controlxy_*     │      │
│  └────────────────────────────────────────────────────────────────────────┘      │
│                                                                                   │
└──────────────────────────────────────────────────────────────────────────────────┘

Key Principles

  1. Annotations: File-based storage — Persists to annotations.json in project folder via REST API
  2. Markers/ControlXY: localStorage — Persists to browser localStorage
  3. Project-scoped — Data is namespaced by project name
  4. WebSocket sync — Changes broadcast to other connected clients in real-time
  5. Server persistence — Server persists annotation changes to file on every WebSocket message
  6. Scope field — Items can be local (this client only) or shared (sync to others)
  7. Version field — Enables future migration paths

Storage Pattern

Annotations (File-Based)

Annotations are stored as JSON files in each project directory:

public/scores/{projectName}/annotations.json

The client communicates with the server via REST API:

Markers & ControlXY (localStorage)

These subsystems use browser localStorage with project-scoped keys:

oscilla_{subsystem}_v{version}:{projectName}

Examples:

oscilla_markers_v1:myScore
oscilla_controlxy_v1:myScore

Data Structure

{
  version: 1,                    // Schema version for migrations
  savedAt: 1706789012345,        // Unix timestamp of last save
  items: [                       // Array of all items
    {
      id: "abc_123xyz",          // Unique identifier
      kind: "annotation",        // Item type discriminator
      scope: "local",            // "local" | "shared"
      createdAt: 1706789000000,
      updatedAt: 1706789012345,
      data: { ... }              // Type-specific payload
    }
  ]
}

Common Module Structure

Each subsystem has a "shared" module that provides:

// State object (single source of truth)
export const state = {
  projectId: null,
  items: [],
  dirty: false
};

// Core CRUD operations
export function addItem(item) { ... }
export function updateItem(id, patch) { ... }
export function deleteItem(id) { ... }
export function findById(id) { ... }
export function findByKind(kind) { ... }

// Persistence
export function loadLocal(project) { ... }
export function saveLocal(project, items) { ... }
export function init(project) { ... }
export function save() { ... }           // Debounced
export function forceSave() { ... }      // Immediate

// Events dispatched
// - `{subsystem}:loaded` — After loading from localStorage
// - `{subsystem}:saved` — After saving to localStorage

Annotations System

Files

Storage Location

public/scores/{project}/annotations.json

REST API Endpoints

GET  /api/annotations/:project  — Load annotations from file
POST /api/annotations/:project  — Save annotations to file

Storage Functions (shared.js)

// Async load from server
export async function loadFromServer(project) { ... }

// Fire-and-forget save to server
export function saveToServer(project, items) { ... }

// Legacy aliases for compatibility
export const loadLocal = loadFromServer;
export const saveLocal = saveToServer;

Item Kinds

Kind Description
annotation Text note positioned on score
trigger Executable annotation (can trigger audio)

Data Schema

{
  id: "ann_abc123",
  kind: "annotation",
  scope: "local",              // or "shared"
  createdAt: 1706789000000,
  updatedAt: 1706789012345,
  data: {
    x: 1500,                   // X position in score coordinates
    y: 200,                    // Y position
    text: "Enter softly",      // Annotation text
    color: "#ffcc00",          // Display color
    fontSize: 14,              // Font size
    visible: true,             // Show/hide state
    
    // Trigger-specific fields (kind: "trigger")
    triggerType: "audioPool",  // "audio" | "audioPool" | "audioImpulse"
    triggerConfig: {
      path: "samples/hits",
      mode: "random",
      amp: 0.8
    }
  }
}

WebSocket Messages

// Client → Server → Other Clients
{ type: "annotation_add", payload: { item } }
{ type: "annotation_update", payload: { id, patch } }
{ type: "annotation_delete", payload: { id } }

API (window.oscillaAnnotations)

window.oscillaAnnotations.add(data)
window.oscillaAnnotations.update(id, patch)
window.oscillaAnnotations.delete(id)
window.oscillaAnnotations.list()
window.oscillaAnnotations.exportJSON()
window.oscillaAnnotations.importJSON(json, merge)

Markers System

Files

Storage Key

oscilla_markers_v1:{project}

Item Kind

Kind Description
marker Timeline position marker

Data Schema

{
  id: "mrk_xyz789",
  kind: "marker",
  scope: "local",
  createdAt: 1706789000000,
  updatedAt: 1706789012345,
  data: {
    x: 5000,                   // Position in score coordinates
    label: "A",                // Marker label (e.g., rehearsal letter)
    color: "#00ff00",          // Display color
    description: "Cue entry"   // Optional description
  }
}

Relationship to Rehearsal Marks

Markers in localStorage are user-created markers. They complement (but don't replace) rehearsal marks authored in the SVG score itself:


ControlXY System

Files

Storage Key

oscilla_controlxy_v1:{project}

Item Kinds

Kind Description
preset Saved handle positions
sequence Ordered list of presets with timing
launcher Per-pad launcher state (banks, slots, mode)
scene Complete snapshot (all positions + all launchers)

Data Schemas

Preset

{
  id: "cxy_abc123",
  kind: "preset",
  name: "center",
  scope: "local",
  createdAt: 1706789000000,
  updatedAt: 1706789012345,
  data: {
    // Positions keyed by pad uid, then handle id
    "pad1": {
      "handle1": { x: 0.5, y: 0.5 },
      "handle2": { x: 0.2, y: 0.8 }
    },
    "_meta": {
      savedAt: 1706789012345,
      filter: null              // Optional uid filter used when saving
    }
  }
}

Sequence

{
  id: "cxy_seq456",
  kind: "sequence",
  name: "sweep",
  scope: "local",
  createdAt: 1706789000000,
  updatedAt: 1706789012345,
  data: {
    steps: [
      { preset: "left", dur: 1, ease: "easeInOutSine" },
      { preset: "center", dur: 0.5, ease: "linear" },
      { preset: "right", dur: 1, ease: "easeOutBack" }
    ],
    loop: true,
    defaultDur: 1,
    defaultEase: "easeInOutSine"
  }
}

Launcher

{
  id: "cxy_lnc789",
  kind: "launcher",
  uid: "pad1",                  // Links to specific controlXY instance
  createdAt: 1706789000000,
  updatedAt: 1706789012345,
  data: {
    currentBank: 0,
    mode: "preset",             // "preset" | "sequence"
    tween: true,
    visible: true,
    banks: [
      {
        name: "Bank 1",
        slots: [
          { type: "preset", name: "center" },
          { type: "preset", name: "corner" },
          { type: "sequence", name: "sweep" },
          { type: "empty" }
        ]
      },
      {
        name: "Bank 2",
        slots: [ ... ]
      }
    ]
  }
}

Scene

{
  id: "cxy_scn012",
  kind: "scene",
  name: "Live Set 1",
  scope: "local",
  createdAt: 1706789000000,
  updatedAt: 1706789012345,
  data: {
    handlePositions: {
      "pad1": { "handle1": { x: 0.5, y: 0.5 } },
      "pad2": { "handle1": { x: 0.1, y: 0.9 } }
    },
    launchers: {
      "pad1": { currentBank: 0, mode: "preset", tween: true, visible: true, banks: [...] },
      "pad2": { currentBank: 1, mode: "sequence", tween: false, visible: true, banks: [...] }
    },
    savedAt: 1706789012345
  }
}

API (window.controlXYPresets)

// Presets
window.controlXYPresets.save(name)
window.controlXYPresets.recall(name, { dur, ease })
window.controlXYPresets.delete(name)
window.controlXYPresets.list()

// Sequences
window.controlXYPresets.defineSequence(name, steps, options)
window.controlXYPresets.playSequence(name, options)
window.controlXYPresets.stopSequence()

// Scenes
window.controlXYPresets.saveScene(name)
window.controlXYPresets.recallScene(name, { dur, ease })
window.controlXYPresets.deleteScene(name)
window.controlXYPresets.listScenes()

// Persistence
window.controlXYPresets.export()
window.controlXYPresets.import(json, merge)

WebSocket Synchronization

Scope Field

The scope field on each item determines sync behavior:

Scope Behavior
local Stays on this client only, no WebSocket broadcast
shared Broadcast to other clients via server relay

Message Flow

Client A                    Server                      Client B
   │                          │                            │
   │  annotation_add          │                            │
   │ ───────────────────────► │                            │
   │                          │  annotation_add            │
   │                          │ ──────────────────────────►│
   │                          │                            │
   │                          │                     (apply to local state)

Server Relay & Persistence (server.js)

The server relays sync messages and persists annotation changes to file:

// server.js handles these message types:

// Annotations: relay + persist to file
case "annotation_add":
case "annotation_update":
case "annotation_delete":
  // Update in-memory state
  annotationsByProject[project][item.id] = item;  // or delete
  // Persist to annotations.json
  saveAnnotationsToFile(project);
  // Broadcast to all other connected clients
  broadcastToOthers(ws, message);
  break;

// Markers: relay only (localStorage on client)
case "marker_add":
case "marker_update":
case "marker_delete":
  broadcastToOthers(ws, message);
  break;

Server Annotation Helpers

// Persist annotations to file
function saveAnnotationsToFile(project) {
  const items = Object.values(annotationsByProject[project] || {});
  const file = path.join(WRITE_DIR, "public", "scores", project, "annotations.json");
  fs.writeFileSync(file, JSON.stringify({ version: 1, savedAt: Date.now(), items }, null, 2));
}

// Load annotations from file (on first request)
function loadAnnotationsFromFile(project) {
  if (annotationsByProject[project]) return;  // Already loaded
  const file = path.join(WRITE_DIR, "public", "scores", project, "annotations.json");
  if (fs.existsSync(file)) {
    const data = JSON.parse(fs.readFileSync(file, "utf8"));
    annotationsByProject[project] = {};
    for (const item of data.items) {
      annotationsByProject[project][item.id] = item;
    }
  }
}

Conflict Resolution

Current strategy: Last Write Wins


Import/Export

Export Format

All subsystems export as JSON with the same structure as localStorage:

{
  version: 1,
  exportedAt: 1706789012345,
  projectId: "myScore",
  items: [ ... ]
}

Import Options

// Merge mode (default): add new items, update existing by id
subsystem.import(json, { merge: true });

// Replace mode: clear existing items, import all
subsystem.import(json, { merge: false });

File Naming Convention

controlxy-presets-1706789012345.json
annotations-myScore-1706789012345.json

Migration & Versioning

Version Field

Each storage structure includes a version field:

{
  version: 1,  // Increment when schema changes
  ...
}

Migration Pattern

function loadLocal(project) {
  const key = `${STORAGE_PREFIX}:${project}`;
  const raw = localStorage.getItem(key);
  const data = JSON.parse(raw);
  
  // Check version and migrate if needed
  if (data.version < CURRENT_VERSION) {
    data = migrateData(data);
    saveLocal(project, data.items);
  }
  
  return data;
}

function migrateData(data) {
  // v1 → v2 migration
  if (data.version === 1) {
    data.items = data.items.map(item => {
      // Apply schema changes
      return { ...item, newField: defaultValue };
    });
    data.version = 2;
  }
  return data;
}

Debugging

Browser Console Commands

// View annotation state
console.log(window.oscillaAnnotations);

// View all controlXY data
console.log(window.controlXYPresets._shared.state);

// List all presets
window.controlXYPresets.list();

// List all scenes
window.controlXYPresets.listScenes();

// View raw localStorage (markers/controlXY only)
localStorage.getItem('oscilla_controlxy_v1:myProject');
localStorage.getItem('oscilla_markers_v1:myProject');

// Force save (bypass debounce)
window.controlXYPresets.forceSave();

// Export to console
console.log(window.controlXYPresets.export());

Annotation File Location

Annotations are stored in the project folder:

public/scores/{projectName}/annotations.json

You can inspect the file directly or use the REST API:

# Load annotations via API
curl http://localhost:3000/api/annotations/myProject

# Check file directly
cat public/scores/myProject/annotations.json

Events to Monitor

// Listen for save events
window.addEventListener('controlxy:saved', (e) => {
  console.log('ControlXY saved:', e.detail);
});

window.addEventListener('controlxy:loaded', (e) => {
  console.log('ControlXY loaded:', e.detail);
});

window.addEventListener('annotation:saved', (e) => {
  console.log('Annotations saved');
});

Common Issues

Issue Cause Solution
Annotations not persisting Server not running Ensure server is running for REST API
Annotations lost on rename Using old localStorage code Update to file-based storage
Presets not in list Using old API Check imports use shared module
Sync not working Scope is "local" Set scope: "shared" on items
Data lost on reload Save not called Check debounce, call forceSave()
Server restart loses sync Normal behavior Server reloads from file on first request

Summary: Comparison Table

Feature Annotations Audio Objects Markers ControlXY
Storage Type File (annotations.json) File (audio-objects.json) localStorage localStorage
Storage Location public/scores/{project}/ public/scores/{project}/ Browser Browser
Server Persistence ✅ Yes ✅ Yes ❌ No ❌ No
REST API ✅ /api/annotations/:project ✅ /api/audio-objects/:project ❌ ❌
Item Kinds annotation, trigger audioObject marker preset, sequence, launcher, scene
WebSocket Sync ✅ Yes ✅ Yes ✅ Yes 🔜 Planned
Scope Support ✅ local/shared ✅ local/shared ✅ local/shared ✅ local/shared
UI Location Annotation overlay Score surface Timeline Preset Manager panel
Trigger Support ✅ Audio triggers ✅ Audio triggers (click + playhead) ❌ ❌
Synced Playback ❌ ✅ Yes (when scope=shared) ❌ ❌
Survives Rename ✅ Yes ✅ Yes ❌ No (keyed by name) ❌ No (keyed by name)

File Reference

File Purpose
server.js REST API endpoints, WebSocket handlers, file persistence
public/js/interaction/shared.js Annotation storage helpers (REST API calls)
public/js/interaction/interactionSurface.js Annotation state, rendering, WebSocket handling
public/js/interaction/annotationEditor.js Annotation editing UI
public/js/interaction/audioObjectState.js Audio object storage helpers (REST API calls)
public/js/interaction/audioObjectSVG.js Audio object SVG rendering, cue registration
public/js/interaction/audioObjectEditor.js Audio object editing UI
public/js/interaction/markers.js Marker management
public/js/control/controlXYShared.js ControlXY localStorage state
public/js/control/controlXYPresets.js Preset/sequence/scene operations
public/js/control/controlXYPresetUI.js Preset Manager panel
public/js/cues/controlXY.js XY pad cue handler

Document version: 1.1 — March 2026 Updated: Annotations now use file-based storage instead of localStorage