const LINE_RE = /^(\d{2}) (\S+(?:\.\S+)*) ?(.*)/; const DEBUG_RE = /^\d{2} # DEBUG/; function coerce(s) { if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true; if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false; if (s === '') return s; const n = Number(s); if (!isNaN(n)) return n; return s; } export function parseAstLog(text) { const root = { slot: 'root', parentSlot: null, depth: -1, positionals: [], props: {}, children: [] }; const stack = [root]; for (const line of text.split('\n')) { if (!line.trim()) continue; if (DEBUG_RE.test(line)) continue; const m = LINE_RE.exec(line); if (!m) continue; const depth = parseInt(m[1], 10); const slotFull = m[2]; const rest = m[3] || ''; const dotIdx = slotFull.indexOf('.'); let parentSlot = null, slot = slotFull; if (dotIdx !== -1) { parentSlot = slotFull.slice(0, dotIdx); slot = slotFull.slice(dotIdx + 1); } const { positionals, props } = parseRest(rest); const node = { slot, parentSlot, depth, positionals, props, children: [] }; // Pop stack to find correct parent (parent must have depth < current) while (stack.length > 1 && stack[stack.length - 1].depth >= depth) { stack.pop(); } if (depth > stack[stack.length - 1].depth + 1) throw new Error(`AST log malformed: depth jump to ${depth} at: ${line}`); if (depth > 0 && !slotFull.includes('.')) throw new Error(`AST log malformed: missing slot at depth ${depth}: ${line}`); stack[stack.length - 1].children.push(node); stack.push(node); } return root; } function parseRest(rest) { const positionals = []; const props = {}; let i = 0; const n = rest.length; let inProps = false; while (i < n) { // skip spaces while (i < n && rest[i] === ' ') i++; if (i >= n) break; if (rest[i] === "'") { // single-quoted value (positional string) const j = rest.indexOf("'", i + 1); const val = rest.slice(i + 1, j === -1 ? n : j); i = j === -1 ? n : j + 1; if (!inProps) positionals.push(val); } else { // scan to next space let j = i; while (j < n && rest[j] !== ' ') j++; const tok = rest.slice(i, j); i = j; const eqIdx = tok.indexOf('='); if (eqIdx !== -1) { inProps = true; const key = tok.slice(0, eqIdx); let rawVal = tok.slice(eqIdx + 1); if (rawVal.startsWith("'")) { // value continues until next single quote const valStart = i - (tok.length - eqIdx - 1); // find closing quote: search from after the opening quote const openPos = rest.indexOf("'", rest.lastIndexOf(key + '=', i) + key.length + 1); const closePos = rest.indexOf("'", openPos + 1); rawVal = closePos === -1 ? rest.slice(openPos + 1) : rest.slice(openPos + 1, closePos); i = closePos === -1 ? n : closePos + 1; } props[key] = coerce(rawVal); } else if (!inProps) { positionals.push(coerce(tok)); } // bare token after props start is ignored (shouldn't happen per spec) } } return { positionals, props }; } // ── Second pass: build typed model ────────────────────────────────────────── function collectUnknownProps(nodeProps, knownKeys) { return Object.fromEntries(Object.entries(nodeProps).filter(([k]) => !knownKeys.has(k))); } export function buildModel(rawTree) { const score = { type: 'score', info: null, tuning: null, articles: [], stageCone: null, stageVoices: [], instruments: [], bars: [], }; // Upstream uses `with deeper_level("articles"):` / `deeper_level("stage"):` // implicit containers that emit no depth-00 header. Their depth-01 lines get // nested under the preceding `00 tuning` by the depth-stack parser even // though they are conceptually siblings of tuning. Un-nest them here. const topLevel = []; for (const node of rawTree.children) { if (node.parentSlot === null && node.slot === 'tuning') { const trulyTuning = []; const misnested = []; for (const child of node.children) { if (child.parentSlot === 'tuning') trulyTuning.push(child); else misnested.push(child); } topLevel.push({ ...node, children: trulyTuning }); topLevel.push(...misnested); } else { topLevel.push(node); } } for (const node of topLevel) { const fqSlot = node.parentSlot ? `${node.parentSlot}.${node.slot}` : node.slot; switch (fqSlot) { case 'info': score.info = { ...node.props }; break; case 'tuning': score.tuning = buildTuning(node); break; case 'stage.cone': score.stageCone = { type: 'stage_cone', ...node.props }; break; case 'stage.voice': score.stageVoices.push({ type: 'stage_voice', name: node.positionals[0], direction: node.props.direction, distance: node.props.distance, }); break; case 'instrument': score.instruments.push(buildInstrument(node)); break; case 'bar': score.bars.push(buildBar(node)); break; default: if (node.parentSlot === 'articles') { mergeArticleEntry(score, node); } else { score[node.slot] = buildGeneric(node); } } } return score; } // Articles are keyed by label. The AST emits one line per (label, subtype) // — e.g. `articles.defaults 'f'` and (future) `articles.overwrites 'f'`. // We merge them into one entry per label whose properties carry the scope // of their originating subtype, so the UI can toggle scope per property. function mergeArticleEntry(score, node) { const name = node.positionals[0]; let entry = score.articles.find(a => a.name === name); if (!entry) { entry = { type: 'article', name, properties: [] }; score.articles.push(entry); } const scope = node.slot; // 'defaults' | 'overwrites' | future subtype for (const [key, value] of Object.entries(node.props)) { entry.properties.push({ name: key, value, scope }); } return entry; } function buildTuning(node) { const t = { type: 'tuning', base: node.props.base, scales: {}, chords: {}, frequencyFactors: null }; for (const child of node.children) { if (child.slot === 'scales') { t.scales[child.positionals[0]] = child.positionals.slice(1); } else if (child.slot === 'chords') { t.chords[child.positionals[0]] = child.positionals.slice(1); } else if (child.slot === 'frequency_factors') { t.frequencyFactors = { label: child.props.label ?? null, factors: child.positionals.slice(), }; } } return t; } function buildInstrument(node) { const instr = { type: 'instrument', name: node.positionals[0], notChangedSince: node.props.NOT_CHANGED_SINCE ?? null, isLinked: (node.positionals[0] ?? '').includes('/'), isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [], rawChildren: [], }; for (const child of node.children) { switch (child.parentSlot + '.' + child.slot) { case 'character.variation': instr.variations.push(buildVariation(child)); break; case 'character.basic_properties': instr.basicProperties = buildBasicProperties(child); break; case 'VOLUMES.shape': instr.volumes = buildShape(child); break; case 'TIMBRE.shape': instr.timbre = buildShape(child); break; case 'FM.modulation': instr.fmModulations.push({ ...child.props }); break; case 'AM.modulation': instr.amModulations.push({ ...child.props }); break; default: instr.rawChildren.push(buildGeneric(child)); } } return instr; } function buildVariation(node) { const v = { type: 'variation', dependsOn: node.props.depends_on ?? node.props.for_value ?? null, basicProperties: null, labelSpecs: [], subvariations: [], spread: null, railsbackCurve: null, rawChildren: [], }; for (const child of node.children) { const key = (child.parentSlot ?? child.slot) + '.' + child.slot; switch (key) { case 'variation.basic_properties': v.basicProperties = buildBasicProperties(child); break; case 'variation.label_spec': v.labelSpecs.push(buildLabelSpec(child)); break; case 'variation.subvariation': v.subvariations.push(buildVariation(child)); break; case 'variation.SPREAD': v.spread = child.positionals; break; case 'RAILSBACK_CURVE.shape': v.railsbackCurve = buildShape(child); break; default: v.rawChildren.push(buildGeneric(child)); } } return v; } function buildBasicProperties(node) { const bp = { type: 'basic_properties', A: null, S: null, R: null, oscillator: null, fmModulations: [], amModulations: [], rawChildren: [], }; for (const child of node.children) { const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; if (child.parentSlot === 'A' && child.slot === 'shape') { bp.A = buildShape(child); } else if (child.parentSlot === 'S' && child.slot === 'shape') { bp.S = buildShape(child); } else if (child.parentSlot === 'R' && child.slot === 'shape') { bp.R = buildShape(child); } else if (child.parentSlot === 'variation' && child.slot === 'O') { bp.oscillator = child.props.ref ?? child.positionals[0]; } else if (child.parentSlot === 'FM' && child.slot === 'modulation') { const fm = { ...child.props }; const envChild = child.children.find(c => c.slot === 'shape'); if (envChild) fm.shape = buildShape(envChild); bp.fmModulations.push(fm); } else if (child.parentSlot === 'AM' && child.slot === 'modulation') { const am = { ...child.props }; const envChild = child.children.find(c => c.slot === 'shape'); if (envChild) am.shape = buildShape(envChild); bp.amModulations.push(am); } else { bp.rawChildren.push(buildGeneric(child)); } } return bp; } function buildLabelSpec(node) { const ls = { type: 'label_spec', label: node.positionals[0], basicProperties: null, rawChildren: [], }; const directBpChildren = []; for (const child of node.children) { if (child.parentSlot === 'variation' && child.slot === 'basic_properties') { ls.basicProperties = buildBasicProperties(child); } else if ( (child.slot === 'shape' && (child.parentSlot === 'A' || child.parentSlot === 'S' || child.parentSlot === 'R')) || (child.parentSlot === 'variation' && child.slot === 'O') || (child.parentSlot === 'FM' && child.slot === 'modulation') ) { directBpChildren.push(child); } else { ls.rawChildren.push(buildGeneric(child)); } } if (!ls.basicProperties && directBpChildren.length > 0) { ls.basicProperties = buildBasicProperties({ children: directBpChildren }); } return ls; } function buildShape(node) { return { type: 'shape', length: node.props.length, start: node.props.start, coords: node.children .filter(c => c.slot === 'coords') .map(c => ({ x: c.props.x, y: c.props.y, z: c.props.z ?? 1, isSharp: c.props.is_sharp ?? false, })), }; } function buildBar(node) { const bar = { type: 'bar', id: node.positionals[0] ?? '', isDirty: false, stressor: null, tempoShape: null, tempoLevels: null, lowerStressBound: null, upperStressBound: null, tunings: [], voices: {}, rawChildren: [], }; for (const child of node.children) { const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; switch (fqSlot) { case 'stress_pattern.stressor': bar.stressor = buildStressor(child); break; case 'tempo.shape': bar.tempoShape = buildShape(child); break; case 'tempo.levels': bar.tempoLevels = child.positionals[0]; break; case 'lower_stress_bound.shape': bar.lowerStressBound = buildShape(child); break; case 'upper_stress_bound.shape': bar.upperStressBound = buildShape(child); break; case 'bar.tuning': bar.tunings.push({ ...child.props }); break; case 'bar.voice': bar.voices[child.positionals[0]] = buildVoice(child); break; default: bar.rawChildren.push(buildGeneric(child)); } } return bar; } function buildStressor(node) { const levels = []; let currentGroup = []; for (const child of node.children) { if (child.slot === 'level') { currentGroup.push(parseInt(child.positionals[0])); } else if (child.slot === 'subdivision') { levels.push(currentGroup); currentGroup = []; } } if (currentGroup.length) levels.push(currentGroup); return { type: 'stressor', groups: levels }; } function buildVoice(node) { const voice = { type: 'voice', name: node.positionals[0], offsets: [], articles: [], motifs: [], }; for (const child of node.children) { const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; switch (fqSlot) { case 'voice.offset': voice.offsets.push(buildOffset(child)); break; case 'voice.article': voice.articles.push(child.positionals[0]); break; case 'voice.motif': voice.motifs.push(buildMotif(child)); break; default: // ignore } } return voice; } function buildOffset(node) { const offset = { type: 'offset', tick: node.props.tick, stemNotes: [], motifs: [], unknownProps: collectUnknownProps(node.props, new Set(['tick'])), }; for (const child of node.children) { if (child.parentSlot === 'line' && child.slot === 'stem_note') offset.stemNotes.push(buildStemNote(child)); else if (child.parentSlot === 'line' && child.slot === 'motif') offset.motifs.push({ label: child.positionals[0], chord: child.props.chord ?? null }); } return offset; } function buildStemNote(node) { const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']); const chainNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'chain'); const writeToNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'write_to'); return { type: 'stem_note', pitch: node.props.pitch, effLength: node.props.eff_length ?? null, adjacent: node.props.adjacent ?? null, adjStress: node.props.adj_stress ?? null, length: null, weight: null, chainText: chainNode?.props._tmp_string ?? '', writeToName: writeToNode?.positionals[0] ?? null, clauses: (chainNode?.children ?? []) .filter(c => c.parentSlot === 'chain' && c.slot === 'clause') .map(buildClause), isDirty: false, unknownProps: collectUnknownProps(node.props, KNOWN), }; } function buildClause(node) { return { type: 'clause', index: node.positionals[0] ?? 0, repeat: node.props.repeat ?? null, notes: node.children.filter(c => c.slot === 'note' && c.parentSlot === 'seq').map(c => ({ ...c.props })), pauses: node.children.filter(c => c.slot === 'pause' && c.parentSlot === 'seq').map(c => ({ length: c.props.length })), stacks: node.children.filter(c => c.slot === 'stack' && c.parentSlot === 'seq').map(c => ({ length: c.props.length, netlength: c.props.netlength, notes: c.children.filter(cn => cn.slot === 'note').map(cn => ({ ...cn.props })), })), }; } function buildMotif(node) { const m = { type: 'motif', label: node.props.label, stemNotes: [], unknownProps: collectUnknownProps(node.props, new Set(['label'])), }; for (const child of node.children) { if (child.parentSlot === 'line' && child.slot === 'stem_note') m.stemNotes.push(buildStemNote(child)); } m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch)); return m; } function buildGeneric(node) { return { type: node.slot, parentSlot: node.parentSlot, depth: node.depth, positionals: node.positionals, props: node.props, children: node.children.map(buildGeneric), }; }