diff --git a/static/ast-parser.js b/static/ast-parser.js index 4bbf867..274ff8b 100644 --- a/static/ast-parser.js +++ b/static/ast-parser.js @@ -42,6 +42,11 @@ export function parseAstLog(text) { 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); } @@ -103,6 +108,10 @@ function parseRest(rest) { // ── 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', @@ -415,7 +424,7 @@ function buildVoice(node) { voice.articles.push(child.positionals[0]); break; case 'voice.motif': - voice.motifs.push(child.props.label); + voice.motifs.push(buildMotif(child)); break; default: // ignore @@ -430,61 +439,61 @@ function buildOffset(node) { type: 'offset', tick: node.props.tick, stemNotes: [], - clusters: [], - chains: [], + unknownProps: collectUnknownProps(node.props, new Set(['tick'])), }; - for (const child of node.children) { - const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; - switch (fqSlot) { - case 'offset.stem_note': - offset.stemNotes.push({ pitch: child.props.pitch, effLength: child.props.eff_length }); - break; - case 'offset.cluster': - offset.clusters.push(buildCluster(child)); - break; - case 'offset.chain': - offset.chains.push({ index: child.positionals[0], children: child.children.map(buildGeneric) }); - break; - default: - // ignore - } + if (child.parentSlot === 'offset' && child.slot === 'stem_note') + offset.stemNotes.push(buildStemNote(child)); } - return offset; } -function buildCluster(node) { - const cluster = { - type: 'cluster', - index: node.positionals[0], - repeat: node.props.repeat ?? null, - notes: [], - pauses: [], - groups: [], - subchains: [], +function buildStemNote(node) { + const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']); + 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: node.children.find(c => c.slot === 'chain')?.props._tmp_string ?? '', + clauses: node.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 === 'clause').map(c => ({ ...c.props })), + pauses: node.children.filter(c => c.slot === 'pause').map(c => ({ length: c.props.length })), + stacks: node.children.filter(c => c.slot === 'stack').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) { - switch (child.slot) { - case 'note': - cluster.notes.push({ ...child.props }); - break; - case 'pause': - cluster.pauses.push({ length: child.props.length }); - break; - case 'group': - cluster.groups.push({ length: child.props.length, netlength: child.props.netlength }); - break; - case 'subchain': - cluster.subchains.push({ length: child.props.length, children: child.children.map(buildGeneric) }); - break; - default: - // ignore - } + if (child.parentSlot === 'motif' && child.slot === 'stem_note') + m.stemNotes.push(buildStemNote(child)); } - - return cluster; + m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch)); + return m; } function buildGeneric(node) { diff --git a/static/components/PaneCP.js b/static/components/PaneCP.js index f1e1670..201d9f7 100644 --- a/static/components/PaneCP.js +++ b/static/components/PaneCP.js @@ -30,6 +30,10 @@ function shortView(node) { return { typeTag: 'voice', label: node.name, meta: [] }; case 'offset': return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] }; + case 'motif': + return { typeTag: 'motif', label: node.label, meta: node.isStatic ? [{ key: 'static', value: '✓' }] : [] }; + case 'stem_note': + return { typeTag: 'stem_note', label: String(node.pitch), meta: [] }; default: return { typeTag: node.type, label: node.type, meta: [] }; } diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js index 1954be7..4031703 100644 --- a/static/components/PaneFO.js +++ b/static/components/PaneFO.js @@ -7,6 +7,11 @@ import { stressorToString } from '../exporter.js'; const H4 = { style: 'margin:0 0 0.5rem' }; +function unknownPropFields(node) { + return Object.entries(node.unknownProps ?? {}) + .map(([key, value]) => ({ key, value: String(value), editable: false })); +} + function parseStressor(str) { const groups = str.split(';').map(seg => seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n)) @@ -174,34 +179,68 @@ export const PaneFO = { h(ObjectExtended, { fields: [ { key: 'articles', value: node.articles.join(', ') || '—', editable: false }, - { key: 'motifs', value: node.motifs.join(', ') || '—', editable: false }, + { key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false }, { key: 'offsets', value: String(node.offsets.length), editable: false }, ], onChange: null, }), ); } else if (node.type === 'offset') { - const noteStr = n => `${n.pitch}${n.effLength != null ? ' ' + n.effLength : ''}`; - const clusterStr = c => c.notes.length - ? c.notes.map(n => `${n.letter ?? ''}${n.shift != null ? n.shift : ''}${n.length != null ? ' ' + n.length : ''}`).join(', ') - : `cluster[${c.index}]`; - const noteItem = (text, i) => h('li', { class: 'se-object-item', key: i }, - h('span', { class: 'se-object-label' }, text)); - + const snLabel = sn => `${sn.pitch}${sn.effLength != null ? ' /' + sn.effLength : ''}${sn.clauses.length ? ' ×' + sn.clauses.length : ''}`; children.push( h('h4', H4, `Tick: ${node.tick}`), h(ObjectExtended, { - fields: [{ key: 'tick', value: node.tick, editable: false }], + fields: [ + { key: 'tick', value: node.tick, editable: false }, + { key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false }, + ...unknownPropFields(node), + ], onChange: null, }), - node.stemNotes.length ? h('div', { style: 'margin-top:0.5rem' }, [ - h('strong', null, 'Stem notes'), - h('ul', { class: 'se-object-list' }, node.stemNotes.map((n, i) => noteItem(noteStr(n), i))), - ]) : null, - node.clusters.length ? h('div', { style: 'margin-top:0.5rem' }, [ - h('strong', null, 'Clusters'), - h('ul', { class: 'se-object-list' }, node.clusters.map((c, i) => noteItem(clusterStr(c), i))), - ]) : null, + ); + } else if (node.type === 'motif') { + const pitchLabel = sn => (Number.isInteger(sn.pitch) ? `(ref${sn.pitch !== 0 ? sn.pitch : ''})` : String(sn.pitch)) + + (sn.clauses.length ? ' ×' + sn.clauses.length : ''); + children.push( + h('h4', H4, `Motif: ${node.label}`), + h(ObjectExtended, { + fields: [ + { key: 'label', value: node.label, editable: false }, + { key: 'static', value: node.isStatic, editable: false, type: 'boolean' }, + { key: 'stem notes', value: node.stemNotes.map(pitchLabel).join(', ') || '—', editable: false }, + ...unknownPropFields(node), + ], + onChange: null, + }), + ); + } else if (node.type === 'stem_note') { + const bar = props.store.focusPath.find(n => n.type === 'bar') ?? null; + const markDirty = () => { + node.isDirty = true; + if (bar) bar.isDirty = true; + props.store.markDirty(); + }; + children.push( + h('h4', H4, `Stem note: ${node.pitch}`), + h(ObjectExtended, { + fields: [ + { key: 'pitch', value: node.pitch, editable: true }, + { key: 'length', value: node.length ?? '', editable: true }, + { key: 'weight', value: node.weight != null ? String(node.weight) : '', editable: true, type: 'number' }, + { key: 'adj_stress', value: node.adjStress != null ? String(node.adjStress) : '', editable: true, type: 'number' }, + { key: 'chain', value: node.chainText, editable: true }, + { key: 'clauses', value: String(node.clauses.length), editable: false }, + ...unknownPropFields(node), + ], + onChange: ({ key, value }) => { + if (key === 'pitch') node.pitch = value; + if (key === 'length') node.length = value || null; + if (key === 'weight') node.weight = value === '' ? null : Number(value); + if (key === 'adj_stress') node.adjStress = value === '' ? null : Number(value); + if (key === 'chain') node.chainText = value; + markDirty(); + }, + }), ); } else { children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' }, diff --git a/static/components/PaneSubObjects.js b/static/components/PaneSubObjects.js index ed2b9cc..cc8bffa 100644 --- a/static/components/PaneSubObjects.js +++ b/static/components/PaneSubObjects.js @@ -51,12 +51,32 @@ export const PaneSubObjects = { } if (node.type === 'bar') { return Object.entries(node.voices).map(([name, v]) => ({ - kind: 'voice', node: v, label: name, hasChildren: v.offsets.length > 0, + kind: 'voice', node: v, label: name, + hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic), })); } if (node.type === 'voice') { - return node.offsets.map((o, idx) => ({ - kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: false, + return [ + ...node.motifs + .filter(m => m.isStatic) + .map(m => ({ kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0 })), + ...node.offsets.map((o, idx) => ({ + kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0, + })), + ]; + } + if (node.type === 'motif') { + return node.stemNotes.map(sn => ({ + kind: 'stem_note', node: sn, + label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`, + hasChildren: false, + })); + } + if (node.type === 'offset') { + return node.stemNotes.map(sn => ({ + kind: 'stem_note', node: sn, + label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`, + hasChildren: false, })); } return []; diff --git a/test-parser.mjs b/test-parser.mjs index 3026f3c..a74cb49 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -429,6 +429,101 @@ ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? const nullInfoPatched = patchScore(META_SCORE, [], [], null); ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title')); +// ── Motif parsing ───────────────────────────────────────────────────────── +section('Motif parsing — dynamic motif'); +const DYN_MOTIF = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.motif label='oct' +03 motif.stem_note pitch=0 +04 chain.clause 0 repeat=1 +05 clause.note letter='o' shift=0 length=1 netlength=1 +05 clause.note letter='o' shift=12 netlength=1 length=1 +`; +const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0]; +const dynVoice = dynBar.voices['pi']; +ok('motif is object', typeof dynVoice.motifs[0] === 'object'); +ok('motif label', dynVoice.motifs[0].label === 'oct'); +ok('motif pitch=0', dynVoice.motifs[0].stemNotes[0].pitch === 0); +ok('dynamic motif isStatic=false', dynVoice.motifs[0].isStatic === false); +ok('dynamic motif has 1 clause', dynVoice.motifs[0].stemNotes[0].clauses.length === 1); +ok('clause has 2 notes', dynVoice.motifs[0].stemNotes[0].clauses[0].notes.length === 2); + +section('Motif parsing — static motif'); +const STAT_MOTIF = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.motif label='cadence' +03 motif.stem_note pitch='C4' +04 chain.clause 0 +05 clause.note letter='o' shift=0 length=1 netlength=1 +`; +const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi']; +ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true); +ok('static motif pitch string', statVoice.motifs[0].stemNotes[0].pitch === 'C4'); + +section('Motif parsing — pause and stack in clause'); +const PS_FIXTURE = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.offset tick=0 +03 offset.stem_note pitch='C4' +04 chain.clause 0 +05 clause.note letter='o' shift=0 length=1 netlength=1 +05 clause.pause length=1 +04 chain.clause 1 +05 clause.stack length=2 netlength=2 +06 stack.note letter='o' shift=0 +06 stack.note letter='e' shift=0 +`; +const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi']; +const psSn = psVoice.offsets[0].stemNotes[0]; +ok('two clauses', psSn.clauses.length === 2); +ok('first clause has 1 note + 1 pause', psSn.clauses[0].notes.length === 1 && psSn.clauses[0].pauses.length === 1); +ok('second clause has 1 stack', psSn.clauses[1].stacks.length === 1); +ok('stack has 2 notes', psSn.clauses[1].stacks[0].notes.length === 2); + +section('Motif parsing — nested chain ignored gracefully'); +const NC_FIXTURE = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.offset tick=0 +03 offset.stem_note pitch='C4' +04 chain.clause 0 +05 clause.note letter='o' shift=0 length=1 netlength=1 +05 clause.chain length=2 +06 chain.clause 0 +07 clause.stack length=1 netlength=1 +08 stack.note letter='o' shift=0 +`; +const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0]; +ok('nested chain: only 1 top-level clause', ncSn.clauses.length === 1); +ok('nested chain: only direct note counted', ncSn.clauses[0].notes.length === 1); +ok('nested chain: no stacks leak from nested chain', ncSn.clauses[0].stacks.length === 0); + +section('Depth-jump guard (abort)'); +const DJ_FIXTURE = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.motif label='oct' +03 motif.stem_note pitch='C4' +06 chain.clause 0 +`; +let djThrew = false; +try { parseAstLog(DJ_FIXTURE); } catch (e) { djThrew = true; } +ok('depth jump throws', djThrew); + +section('No-slot guard (abort)'); +const NS_FIXTURE = `00 bar '001P1L1M1' +01 voice +`; +let nsThrew = false; +try { parseAstLog(NS_FIXTURE); } catch (e) { nsThrew = true; } +ok('missing slot throws', nsThrew); + +section('Full fixture integration'); +ok('fixture parses without error (checked above)', raw && raw.slot === 'root'); +ok('fixture has 432 bars', model.bars.length === 432); +const voicesWithMotifs = model.bars.flatMap(b => Object.values(b.voices)).filter(v => v.motifs.length > 0); +ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m))); +const offsetsWithStemNotes = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.stemNotes.length > 0); +ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn))); + // ── Summary ──────────────────────────────────────────────────────────────── console.log(`\n══ ${pass} passed, ${fail} failed ══\n`); process.exit(fail > 0 ? 1 : 0);