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:
- Animation registry --
oscillaAnimRegistry[uid].el(exact uid match) - DOM --
document.getElementById(id)(exact id match) - 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:
- All element IDs into a
<datalist>for target autocomplete - DSL cue expression IDs into the output panel as browsable entries
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:
click-- selects the element, exits pick modemouseover-- adds.livecode-highlightoutlinemouseout-- removes highlight
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.
Related
Tip: use ← → or ↑ ↓ to navigate the docs