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:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 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_* │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
annotations.json in project folder via REST APIlocal (this client only) or shared (sync to others)Annotations are stored as JSON files in each project directory:
public/scores/{projectName}/annotations.json
The client communicates with the server via REST API:
GET /api/annotations/:project — Load annotationsPOST /api/annotations/:project — Save annotationsThese subsystems use browser localStorage with project-scoped keys:
oscilla_{subsystem}_v{version}:{projectName}
Examples:
oscilla_markers_v1:myScore
oscilla_controlxy_v1:myScore
{
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
}
]
}
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
public/js/interaction/interactionSurface.js — Main annotation logic and initializationpublic/js/interaction/annotationEditor.js — UI for editingpublic/js/interaction/shared.js — Storage helpers (REST API calls)server.js — REST API endpoints and WebSocket persistencepublic/scores/{project}/annotations.json
GET /api/annotations/:project — Load annotations from file
POST /api/annotations/:project — Save annotations to file
// 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;
| Kind | Description |
|---|---|
annotation |
Text note positioned on score |
trigger |
Executable annotation (can trigger audio) |
{
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
}
}
}
// Client → Server → Other Clients
{ type: "annotation_add", payload: { item } }
{ type: "annotation_update", payload: { id, patch } }
{ type: "annotation_delete", payload: { id } }
window.oscillaAnnotations.add(data)
window.oscillaAnnotations.update(id, patch)
window.oscillaAnnotations.delete(id)
window.oscillaAnnotations.list()
window.oscillaAnnotations.exportJSON()
window.oscillaAnnotations.importJSON(json, merge)
public/js/interaction/markers.js — Marker managementoscilla_markers_v1:{project}
| Kind | Description |
|---|---|
marker |
Timeline position marker |
{
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
}
}
Markers in localStorage are user-created markers. They complement (but don't replace) rehearsal marks authored in the SVG score itself:
public/js/control/controlXYShared.js — Single source of truth (localStorage)public/js/control/controlXYPresets.js — Preset/sequence/scene logicpublic/js/control/controlXYPresetUI.js — Panel UIpublic/js/cues/controlXY.js — XY pad cue handleroscilla_controlxy_v1:{project}
| 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) |
{
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
}
}
}
{
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"
}
}
{
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: [ ... ]
}
]
}
}
{
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
}
}
// 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)
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 |
Client A Server Client B
│ │ │
│ annotation_add │ │
│ ───────────────────────► │ │
│ │ annotation_add │
│ │ ──────────────────────────►│
│ │ │
│ │ (apply to local state)
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;
// 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;
}
}
}
Current strategy: Last Write Wins
updatedAt timestamp winsAll subsystems export as JSON with the same structure as localStorage:
{
version: 1,
exportedAt: 1706789012345,
projectId: "myScore",
items: [ ... ]
}
// 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 });
controlxy-presets-1706789012345.json
annotations-myScore-1706789012345.json
Each storage structure includes a version field:
{
version: 1, // Increment when schema changes
...
}
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;
}
// 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());
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
// 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');
});
| 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 |
| 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 | 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