Document Version: 1.0
Date: January 2026
Status: Planning Phase
The OscillaScore CueDSL parser (oscillaParser.js) has grown organically through bug fixes and feature additions, resulting in ~2500 lines of redundant code. This document outlines a comprehensive plan to refactor it into a unified, maintainable architecture of approximately 400-600 lines.
cueXxxTop rulesGrammar rules that are nearly identical:
cueAudioTop, cueAudioPoolTop, cueAudioImpulseTopcueOscTop, cueOscCtrlTop, cueOscCtrlNodeTopcueTextTop, cueMetronomeTop, cueStopwatchTopKEYWORD + genericParamListDuplicate value extraction logic:
extractValue() (lines 2264-2343)extractValueExpr() (lines 1491-1539)extractAnimKvArgs() (lines 2377-2441)extractNumber() (lines 1181-1188)parseVal() in cueSpeed handlerCopy-paste CST→AST blocks:
| File | Primary AST Access Pattern |
|---|---|
| oscillaFade.js | ast.args[] → Object.fromEntries |
| oscillaNav.js | ast.action, ast.target, ast.params |
| oscillaPage.js | ast.pattern, ast.onCompletion |
| oscillaButton.js | ast.triggerAst, ast.label, ast.opt |
| oscillaPause.js | ast.dur, ast.next, ast.count |
| oscillaStop.js | ast.next |
| oscillaVideo.js | ast.params.* |
| oscillaText.js | ast.args[] → params object |
| oscillaOSC.js | ast.args[] |
| oscillaOscCtrl.js | args[] with .type/.value |
| oscillaMetro.js | ast.args[] → params object |
| oscillaTimers.js | ast.args[] → params object |
| oscillaSpeed.js | ast.value, ast.dur (flat) |
| oscillaAudio.js | ast.src, ast.amp + ast.params |
| oscillaAnimationRotate.js | astArgs[] with .key/.type/.value |
| oscillaAnimationScale.js | astArgs[] with .key/.type/.value |
| oscillaAnimationO2p.js | (Similar to rotate/scale) |
Pattern A (Array-based): ~10 files
const params = {};
for (const a of ast.args) {
params[a.type] = a.value;
}
Pattern B (Flat properties): ~5 files
const { src, amp, dur, uid } = ast;
Replace 20+ cueXxxTop rules with:
// Single token for all cue keywords
const CueKeyword = createToken({
name: "CueKeyword",
pattern: /fade|page|stopwatch|video|text|audio|audioPool|audioImpulse|osc|oscCtrl|oscCtrlNode|nav|stop|pause|rotate|scale|scaleXY|o2p|button|metro|metronome/,
longer_alt: Identifier
});
// One rule to parse all cues
$.RULE("cueTop", () => {
$.OPTION(() => {
$.CONSUME(Cue);
$.CONSUME(Colon);
});
$.CONSUME(CueKeyword, { LABEL: "cueType" });
$.SUBRULE($.paramBlock);
});
// Unified parameter handling
$.RULE("paramBlock", () => {
$.CONSUME(LParen);
$.OPTION(() => $.SUBRULE($.paramList));
$.CONSUME(RParen);
});
$.RULE("param", () => {
$.OR([
// Named: key:value
{ GATE: () => $.LA(2).tokenType === Colon,
ALT: () => $.SUBRULE($.namedParam) },
// Positional: just a value
{ ALT: () => $.SUBRULE($.valueExpr, { LABEL: "positional" }) }
]);
});
One rule to handle all value types:
$.RULE("valueExpr", () => {
$.OR([
// Pattern call: Pseq([...], n)
{ GATE: () => $.LA(1).tokenType === PatternName,
ALT: () => $.SUBRULE($.patternCall) },
// Function call: rand(1, 10), fadein(500)
{ GATE: () => $.LA(1).tokenType === Identifier && $.LA(2).tokenType === LParen,
ALT: () => $.SUBRULE($.funcCall) },
// Nested cue (for button triggers)
{ GATE: () => $.LA(1).tokenType === CueKeyword,
ALT: () => $.SUBRULE($.cueTop) },
// Style block: style:{bg:"red", font:"mono"}
{ GATE: () => $.LA(1).image === "style" && $.LA(2).tokenType === Colon,
ALT: () => $.SUBRULE($.styleBlock) },
// Array: [1, 2, 3]
{ ALT: () => $.SUBRULE($.arrayLiteral) },
// Primitives
{ ALT: () => $.CONSUME(NumberLiteral) },
{ ALT: () => $.CONSUME(StringLiteral) },
{ ALT: () => $.CONSUME(RangeLiteral) },
{ ALT: () => $.CONSUME(True) },
{ ALT: () => $.CONSUME(False) },
{ ALT: () => $.CONSUME(Identifier) },
]);
});
Every cue produces a consistent structure:
interface UnifiedAST {
type: string; // "cueFade", "cueNav", etc.
params: Record<string, any>; // ALL params as flat object
uid: string; // Always present
// Optional nested structures (only when needed):
pattern?: PatternAST; // For page, rotate, scale, o2p
after?: UnifiedAST; // Nested cue for chaining
trigger?: UnifiedAST; // For button
style?: Record<string, any>; // Parsed style block
}
interface PatternAST {
type: "Pseq" | "Prand" | "Pxrand" | "Pshuf" | "Pchoose";
list: (string | number | PatternAST)[];
repeats: number | "inf";
}
Replace giant if/else chains with configuration:
const CUE_DEFINITIONS = {
fade: {
positionalKey: "mode",
defaults: { dur: 1, from: 0, to: 1 },
deriveUid: (p) => `fade_${p.mode || 'in'}_${Date.now()}`
},
audio: {
positionalKey: "src",
defaults: { amp: 1, loop: 1, toggle: false, fadeIn: 0, fadeOut: 0 },
deriveUid: (p) => p.uid || p.src
},
nav: {
positionalKey: "action",
defaults: {},
deriveUid: (p) => p.target ? `${p.action}@${p.target}` : p.action
},
page: {
positionalKey: "pattern",
supportsPatterns: true,
defaults: {},
deriveUid: (p) => `page_${Date.now()}`
},
pause: {
positionalKey: "dur",
defaults: { count: false },
deriveUid: (p) => `pause_${p.dur || 0}`
},
stop: {
positionalKey: "scope",
defaults: {},
deriveUid: (p) => p.uid || "stop"
},
rotate: {
positionalKey: "values",
supportsPatterns: true,
defaults: { dur: 1, mode: "loop", ease: "linear" },
deriveUid: (p) => p.uid || `rotate_${Date.now()}`
},
scale: {
positionalKey: "values",
supportsPatterns: true,
defaults: { dur: 1, mode: "loop", ease: "linear" },
deriveUid: (p) => p.uid || `scale_${Date.now()}`
},
// ... etc for all 18 cue types
};
function cstToAst(cst) {
const cueType = cst.children.cueType[0].image;
const def = CUE_DEFINITIONS[cueType] || {};
const params = extractParams(cst.children.paramBlock[0], def.positionalKey);
// Handle patterns if supported
let pattern = null;
if (def.supportsPatterns && params[def.positionalKey]?.type === "pattern") {
pattern = params[def.positionalKey];
delete params[def.positionalKey];
}
// Handle nested cues (after/trigger)
let after = null;
if (params.after) {
after = params.after;
delete params.after;
}
return {
type: `cue${capitalize(cueType)}`,
params: { ...def.defaults, ...params },
uid: def.deriveUid?.(params) || params.uid || generateUid(),
...(pattern && { pattern }),
...(after && { after })
};
}
function extractValue(node) {
if (!node) return null;
// Unwrap CST wrappers
if (node.children?.valueExpr) return extractValue(node.children.valueExpr[0]);
if (node.children?.animValue) return extractValue(node.children.animValue[0]);
// Handle tokens directly
if (node.image !== undefined) {
const img = node.image;
const typeName = node.tokenType?.name;
if (typeName === "StringLiteral") return unquote(img);
if (typeName === "NumberLiteral") return Number(img);
if (typeName === "True") return true;
if (typeName === "False") return false;
if (typeName === "RangeLiteral") return parseRange(img);
if (img === "inf") return "inf";
return img; // Identifier
}
// Handle composite nodes
if (node.name === "funcCall") return extractFuncCall(node);
if (node.name === "patternCall") return extractPatternCall(node);
if (node.name === "arrayLiteral") return node.children.valueExpr.map(extractValue);
if (node.name === "cueTop") return cstToAst(node); // Nested cue
if (node.name === "styleBlock") return extractStyleBlock(node);
// Recurse into children
for (const key of Object.keys(node.children || {})) {
const child = node.children[key]?.[0];
if (child) return extractValue(child);
}
return null;
}
| Topic | Decision | Rationale |
|---|---|---|
| First positional | Allowed, maps to cue-specific key | Preserves speed(3), pause(12) shorthand |
| Pattern location | First positional or any value | Maintains flexibility for animation cues |
| Style syntax | style:{bg:"red", font:"mono"} |
Follows CSS-like convention with quotes |
| After/next unification | Use after: everywhere |
Clearer semantics than "next" |
| Nested cues | Full cue expression required | after:nav(scroll) not after:scroll |
| Topic | Decision | Rationale |
|---|---|---|
| Params structure | Always params: {} object |
Consistency across all cue types |
| UID | Always present, derived if not explicit | Simplifies consumer code |
| Pattern storage | Separate pattern field when present |
Clear separation from scalar params |
| Style storage | Separate style field |
Matches CSS mental model |
oscillaParserV2.js alongside existing parserBefore:
const params = {};
for (const p of (ast.args || [])) {
params[p.type] = p.value;
}
const dur = Number(params.dur || 1);
After:
const { dur = 1 } = ast.params;
Before:
const { src, amp = 1, loop = 1, uid } = ast;
After:
const { src, amp = 1, loop = 1 } = ast.params;
const { uid } = ast;
Before:
if (ast.onCompletion) {
const { control, arg, target } = ast.onCompletion;
handleCueTrigger(`${control}(${arg})`);
}
After:
if (ast.after) {
handleCueTrigger(ast.after);
}
// Input
"fade(in)"
// Expected AST
{ type: "cueFade", params: { mode: "in", dur: 1, from: 0, to: 1 }, uid: "fade_in_..." }
// Input
"audio(src:\"click.wav\", amp:0.5)"
// Expected AST
{ type: "cueAudio", params: { src: "click.wav", amp: 0.5, loop: 1, ... }, uid: "click.wav" }
// Input
"pause(5)"
// Expected AST
{ type: "cuePause", params: { dur: 5, count: false }, uid: "pause_5" }
// Input
"page(Pseq([page1, page2], 2))"
// Expected AST
{
type: "cuePage",
params: {},
pattern: { type: "Pseq", list: ["page1", "page2"], repeats: 2 },
uid: "page_..."
}
// Input
"rotate(Prand([0, 90, 180], inf), dur:2)"
// Expected AST
{
type: "cueRotate",
params: { dur: 2, mode: "loop", ease: "linear" },
pattern: { type: "Prand", list: [0, 90, 180], repeats: "inf" },
uid: "rotate_..."
}
// Input
"button(label:\"Go\", trigger:nav(scroll))"
// Expected AST
{
type: "cueButton",
params: { label: "Go" },
trigger: { type: "cueNav", params: { action: "scroll" }, uid: "scroll" },
uid: "button_..."
}
// Input
"stop(after:nav(sectionB))"
// Expected AST
{
type: "cueStop",
params: {},
after: { type: "cueNav", params: { action: "sectionB" }, uid: "sectionB" },
uid: "stop"
}
// Input
"button(label:\"Go\", trigger:nav(scroll), style:{bg:\"red\", font:\"mono\"})"
// Expected AST
{
type: "cueButton",
params: { label: "Go" },
trigger: { type: "cueNav", params: { action: "scroll" }, uid: "scroll" },
style: { bg: "red", font: "mono" },
uid: "button_..."
}
| Task | Lines of Code | Time Estimate |
|---|---|---|
| Unified grammar | ~150 lines | 2-3 hours |
| Value extractor | ~80 lines | 1-2 hours |
| Cue definitions config | ~100 lines | 1-2 hours |
| CST→AST converter | ~100 lines | 2-3 hours |
| Test suite | ~200 lines | 2-3 hours |
| Consumer migrations | ~100 changes | 4-6 hours |
| Total | ~600 lines (down from 2500) | 12-19 hours |
| Risk | Mitigation |
|---|---|
| Edge cases in existing cues | Comprehensive test suite comparing V1 vs V2 output |
| Pattern handling complexity | Keep pattern extraction as separate, well-tested module |
| Consumer breakage | Parallel parser + adapter layer enables gradual migration |
| Undocumented syntax variations | Run both parsers in parallel during transition, log differences |
oscillaParser.js (current, 2505 lines)| Cue Type | Positional Key | Supports Patterns | Has Nested Cue |
|---|---|---|---|
| fade | mode | No | No |
| audio | src | No | No |
| audioPool | path | No | No |
| audioImpulse | path | No | No |
| nav | action | No | No |
| page | pattern | Yes | after |
| pause | dur | No | after |
| stop | scope | No | after |
| text | content | No | No |
| video | file | No | No |
| osc | addr | No | No |
| oscCtrl | addr | No | No |
| oscCtrlNode | addr | No | No |
| button | (none) | No | trigger |
| metronome | (none) | No | No |
| stopwatch | (none) | No | No |
| rotate | values | Yes | No |
| scale | values | Yes | No |
| o2p | values | Yes | No |
Document generated from analysis session, January 2026