Score

Developer Guide: Live Console

Technical reference for oscillaLive.js and livecode.css.


Architecture

The live console is deliberately thin. It creates a UI panel and routes user input through the existing dispatch pipeline -- it does not import or reimplement any cue handlers.

Score view (normal)

User types DSL string
  |
  v
parseCueToAST(line)          -- validate syntax
  |
  v
handleCueTrigger(line,       -- dispatch through standard pipeline
  isRemote=false,
  force=true,                -- bypass dedupe (always execute)
  cueElement=targetEl|null)
  |
  v
cueDispatcher switch(ast.type) --> handler

Dedicated view (?view=live)

User types DSL string
  |
  v
sendSocketMessage("livecode_exec", {   -- send via WebSocket
  cueExpr: cleaned,
  targetId: targetId | null
})
  |
  v
server.js rebroadcasts via broadcastToOthers()
  |
  v
socket.js handleLivecodeExec(data)     -- score window receives
  |
  v
handleCueTrigger(cueExpr, false, true, targetEl)

In the dedicated view there is no local SVG, no cue pipeline, and no audio context. All execution happens remotely in the score window.


Dependencies

Window globals

Global Source Used for
window.handleCueTrigger cueDispatcher.js Dispatch DSL strings (score view only)
window.parseCueToAST parser.js Validate before dispatch (score view only)
window.oscillaAnimRegistry animation.js Target lookup by uid (score view only)
window.runningAnimations animation.js (Map) Check running state
window.oscillaParamBus paramBus.js Signal monitor
window.oscillaRouter controlRouter.js (future) mod patching

Module imports

Import From Used for
makeDraggable ../system/uiUtils.js Panel drag by header
isDedicatedView ../system/oscillaView.js Branch between local and remote execution
sendSocketMessage ../system/socket.js Send livecode_exec to server

Files

File Location Purpose
oscillaLive.js js/interaction/ Module: panel, execution, picker, browser, signal monitor
liveComplete.js js/interaction/ Pure: what to suggest at the cursor (cues, keys, values, names, stashed phrases)
liveStash.js server/ Pure: parse / append a stash; name a guest performer's file
livecode.css css/ All styling for the panel, picker, and cue browser

Integration

CSS

Add to styles.css imports:

@import url("livecode.css");

JS

In app.js:

import { initLiveConsole, showLiveConsole } from "./interaction/oscillaLive.js";

Call inside DOMContentLoaded (both score and dedicated view paths):

initLiveConsole();

For dedicated views, pass showLiveConsole to activateView so the panel opens automatically:

activateView({ openLiveConsole: showLiveConsole });

HTML

No changes required. The button and panel are created dynamically.


Exports

export function initLiveConsole()       // Setup: creates topbar ">_" button
export function destroyLiveConsole()    // Teardown: removes button + panel
export { openPanel as showLiveConsole } // Open the panel programmatically

Execution Model

Checked before it runs

Each expression goes through checkExpression() (liveEval.js) before anything is dispatched. A composite (audio(...) rotate(...)) is split with splitCueId and every part is parsed; one bad part stops the whole line. A parse error is shown in the output with a caret under the offending word (parseCueToAST attaches chevrotain's parseErrors to the error it throws).

For 2.5 s after an evaluation, console.warn / console.error and unhandled rejections are mirrored into the output too. That is how a handler's own complaint (a 404 on an audio file, a bad parameter) reaches the performer without devtools.

Element-targeted cues

trans, rotate, scale, o2p, color, pin and a non-control draw act on an element (ELEMENT_CUE_TYPES, decided from the parsed AST type). If the user has not picked a target, execution is blocked with an error message. Each part of a composite is dispatched on its own, and only the element parts are handed the target. A synth given an element would draw its scope there.

Console-only commands

audiopath(...) is intercepted before the cue pipeline. It is not a DSL cue -- it sends HTTP requests directly to the server to manage the external audio search path:

Syntax HTTP Effect
audiopath(/some/path) POST /api/audio-search-path Set external audio directory
audiopath() / audiopath(status) GET /api/audio-search-path Query current path
audiopath(clear) DELETE /api/audio-search-path Remove external path

This works in both score view and dedicated view since it uses HTTP, not the cue pipeline or WebSocket.

In the dedicated view, if the expression itself is a known element ID from the score (i.e. it was inserted via the cue browser), it is used as both the cueExpr and targetId, since the element's SVG id attribute contains the DSL expression.

Elementless cues

Everything else (synth, audio, speed, nav, osc, stop, pause, text, video, etc.) dispatches with cueElement = null.

Force flag

All dispatches pass force=true to handleCueTrigger, which bypasses the dedupe guard (window.triggeredCues Set). This is essential -- livecoded cues must always execute even if the same DSL string was triggered before.

Re-application on same element

When a new animation cue is dispatched to an element that already has a running animation, the handler itself manages teardown. The console does not need to stop anything explicitly.

Multi-line expressions

scanExpressions() (liveEval.js) is the one reading of the editor that both run line and run all use. An expression continues onto the next line while a (, [ or { is open; brackets inside quotes do not count. A blank line always ends an expression, so a forgotten ) spoils one block and not everything below it. A // comment may also trail a line.


Target Resolution

Score view

The picker and target input resolve elements through three paths, in order:

  1. Animation registry -- oscillaAnimRegistry[uid].el (exact uid match)
  2. DOM -- document.getElementById(id) (exact id match)
  3. Partial registry match -- first key in registry containing the input string

When an element is picked, its SVG id attribute (which contains the DSL expression) is pre-filled into the editor so the user can modify and re-execute.

Dedicated view

No local DOM is available. Target resolution stores the ID string in selectedTargetId and sends it to the score window, where handleLivecodeExec in socket.js performs the same three-path resolution locally.

The target input has a <datalist> populated by fetchScoreElements() so the user can autocomplete element IDs from the score.


Remote Execution (Dedicated View)

When isDedicatedView() is true, the module operates in remote mode:

On open: fetchScoreElements() fetches the project SVG via HTTP (e.g. /scores/demo-audio/score.svg), parses it with DOMParser, and extracts:

On execute: executeLine() sends a livecode_exec WebSocket message instead of calling handleCueTrigger directly.

Pick button: Disabled (no local SVG to click on). Target selection is done via the datalist autocomplete or by typing.

WebSocket Protocol

Client -> Server:

{ "type": "livecode_exec", "cueExpr": "synth(wave:sin, freq:440)", "targetId": null }

Server -> Other clients: Same message, rebroadcast via broadcastToOthers().

Score window handler (socket.js):

function handleLivecodeExec(data) {
  const { cueExpr, targetId } = data;
  let targetEl = null;
  if (targetId) {
    const reg = window.oscillaAnimRegistry?.[targetId];
    targetEl = reg?.el || document.getElementById(targetId);
  }
  handleCueTrigger(cueExpr, false, true, targetEl);
}

Cue Browser

The cue browser allows keyboard navigation of DSL expressions found in the score. It is populated by fetchScoreElements() in dedicated view mode.

Entry: Ctrl+Shift+E from the editor textarea (Ctrl+J was the browser's downloads).

Navigation: Arrow keys or j/k (vi-style). The current entry is highlighted with a left border accent.

Insert: Enter inserts the full cue expression at the cursor position in the editor and returns focus.

Exit: Escape returns to the editor without inserting.

The handler attaches at document level in capture phase so the textarea does not receive navigation keys while browsing. On exit, the handler is removed.


Element Picker

Pick mode attaches three listeners on the document in capture phase:

findMeaningfulElement() walks up from the clicked element looking for the nearest ancestor with data-anim-uid or a non-livecode id. This avoids selecting leaf <tspan> or <path> nodes when the user means the parent group.

Disabled in dedicated views (no local SVG).


Signal Monitor

Subscribes to all ParamBus changes via wildcard:

oscillaParamBus.subscribe("*", (value, path) => { ... })

Values accumulate in a snapshot object. A setInterval at 200ms (5 fps) renders the snapshot to DOM. This avoids DOM thrash from high-frequency signal updates (animations publish at ~60fps).

The filter input narrows the display by case-insensitive substring match on the signal path.


Code Layer

The textarea's text is transparent. Behind it, .livecode-mirror (a <pre>) shows the same text from highlightCode() (liveHighlight.js): one .lc-line span per line, token spans inside. Both share font, padding, wrapping and scrollbar-gutter, so the caret stays on the text. Every write to the editor goes through setEditorText() or insertText() and re-renders the layer; scrolling is copied across. flashLines() marks the lines of an expression that has just run for 350 ms.

Perform Mode

.livecode-perform on the panel (setPerformMode()): fixed to the whole viewport with !important over the inline drag/resize geometry, everything but the header, editor and output hidden, pointer-events: none on the panel and textarea so clicks reach the score. A capture-phase pointerup puts focus back in the editor unless the click focused something else. Font size is kept separately (--lc-perform-font-size, oscilla.livecode.performFontSize).

In perform mode the non-blank .lc-line spans of the code layer take the pointer (CSS), the rest of the layer does not: a click on code maps to a text offset with caretPositionFromPoint and a Range from the layer's start (textOffsetAt()), which is exact because the layer holds the editor's text character for character. Drag selects, double click takes a word, wheel scrolls the editor.

Hush (Ctrl+.) calls killAllAudio() from audioFile.js, which is the same function behind the stop button and a conductor's kill_audio.

Panel Layout

The panel has three vertically stacked sections with draggable resize bars between them:

Section Content
Editor Textarea for DSL input, run line / run all buttons
Output Log entries and browsable cue list
Signals Live ParamBus signal monitor with filter

The panel itself is edge-resizable (all four edges and corners) and draggable by its header bar.


Keyboard Isolation

The panel stops propagation of keydown, keyup, and keypress events at the panel boundary. This prevents transport keybindings (arrow keys, spacebar, etc.) from firing while the user types in the editor or input fields.


Z-Index

The panel sits at z-index: 38000, between the top bar (35000) and controlXY panels (40000). This ensures it is above the score and transport but below modal controlXY surfaces.


Stash

Ctrl+Shift+S (editor keydown) posts the lines under the cursor or selection to POST /api/live-stash/:project; the server appends them to the project's vocab.live with addToStash() and answers with the parsed phrases. loadStash() fetches GET /api/live-stash/:project whenever a project's buffer is restored, and completionContext() passes the phrases to suggest() as ctx.phrases.

In suggest() a phrase matches the whole line before the caret (indent aside), not the word: a prefix match is put first (so it can be the ghost, even after ( or a space), a match from the middle goes last with replace (so it never is). ghostShown() draws only the first 40 characters of a ghost's first line: the ghost is in-flow in the mirror, so a long or multi-line one would wrap and push the code below out from under the textarea. Tab still inserts the whole phrase.

Which file: stashFileFor(req) in server.js. A request from the host (isLocalRequest) gets vocab.live, whatever name it sends; any other gets vocab-<name>.live from the ?performer= the console sends (window.localClientName), cut to a safe file name by performerStashFile(), and 400 without one. So a guest can never reach vocab.live -- but guest names are self-declared, so a guest's stash is reachable by anyone on the network who sends that name.

The global stash is vocab.live in the user projects folder (globalStashFile() in server.js, beside dsl-favourites.txt). GET answers it as global: { file, phrases } to the host only (null to a guest); POST with scope: "global" appends to it, 403 from a guest. The console keeps the two apart (setPhrases, globalPhrases) and mergeStash() offers the set's first, the global ones not already among them after. Ctrl+Alt+Shift+S is stashAtCursor(true).

No .live file reaches the audience: a middleware ahead of every static mount answers 404 for any path ending .live (decoded first, so %2E does not slip by), and EXPORT_EXCLUDE leaves **/*.live out of .oscilla exports. Followers (FOLLOW) never load a stash.


Tip: use ← → or ↑ ↓ to navigate the docs