Adapt parser and tests to new sompyler AST slot names

Sompyler commit c3b51bc renamed slots across the board:
- offset.stem_note → line.stem_note (parentSlot 'offset' → 'line')
- motif.stem_note → line.stem_note (parentSlot 'motif' → 'line')
- clause.note/pause/stack → seq.note/pause/stack (parentSlot 'clause' → 'seq')
- stem_note.chain is now a real hierarchical node (depth 04) between
  line.stem_note and chain.clause; clauses are now its children, not
  direct children of stem_note

Also adds:
- offset.motifs[] for line.motif invocations at tick positions
- stem_note.writeToName from stem_note.write_to positional
- adjacent prop already stored; new tests verify True/False/absent

PaneFO.js: show offset motif invocations inline (label + chord)
test-parser.mjs: update synthetic fixtures to new depths/slot names;
  add tests for line.motif, stem_note.write_to, adjacent prop
This commit is contained in:
c0dev0id 2026-06-27 18:03:14 +02:00
parent 7fd29ef9b4
commit 859e62e143
3 changed files with 99 additions and 32 deletions

View File

@ -439,17 +439,22 @@ function buildOffset(node) {
type: 'offset', type: 'offset',
tick: node.props.tick, tick: node.props.tick,
stemNotes: [], stemNotes: [],
motifs: [],
unknownProps: collectUnknownProps(node.props, new Set(['tick'])), unknownProps: collectUnknownProps(node.props, new Set(['tick'])),
}; };
for (const child of node.children) { for (const child of node.children) {
if (child.parentSlot === 'offset' && child.slot === 'stem_note') if (child.parentSlot === 'line' && child.slot === 'stem_note')
offset.stemNotes.push(buildStemNote(child)); 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; return offset;
} }
function buildStemNote(node) { function buildStemNote(node) {
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']); 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 { return {
type: 'stem_note', type: 'stem_note',
pitch: node.props.pitch, pitch: node.props.pitch,
@ -458,8 +463,9 @@ function buildStemNote(node) {
adjStress: node.props.adj_stress ?? null, adjStress: node.props.adj_stress ?? null,
length: null, length: null,
weight: null, weight: null,
chainText: node.children.find(c => c.slot === 'chain')?.props._tmp_string ?? '', chainText: chainNode?.props._tmp_string ?? '',
clauses: node.children writeToName: writeToNode?.positionals[0] ?? null,
clauses: (chainNode?.children ?? [])
.filter(c => c.parentSlot === 'chain' && c.slot === 'clause') .filter(c => c.parentSlot === 'chain' && c.slot === 'clause')
.map(buildClause), .map(buildClause),
isDirty: false, isDirty: false,
@ -472,9 +478,9 @@ function buildClause(node) {
type: 'clause', type: 'clause',
index: node.positionals[0] ?? 0, index: node.positionals[0] ?? 0,
repeat: node.props.repeat ?? null, repeat: node.props.repeat ?? null,
notes: node.children.filter(c => c.slot === 'note' && c.parentSlot === 'clause').map(c => ({ ...c.props })), notes: node.children.filter(c => c.slot === 'note' && c.parentSlot === 'seq').map(c => ({ ...c.props })),
pauses: node.children.filter(c => c.slot === 'pause').map(c => ({ length: c.props.length })), 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').map(c => ({ stacks: node.children.filter(c => c.slot === 'stack' && c.parentSlot === 'seq').map(c => ({
length: c.props.length, netlength: c.props.netlength, length: c.props.length, netlength: c.props.netlength,
notes: c.children.filter(cn => cn.slot === 'note').map(cn => ({ ...cn.props })), notes: c.children.filter(cn => cn.slot === 'note').map(cn => ({ ...cn.props })),
})), })),
@ -489,7 +495,7 @@ function buildMotif(node) {
unknownProps: collectUnknownProps(node.props, new Set(['label'])), unknownProps: collectUnknownProps(node.props, new Set(['label'])),
}; };
for (const child of node.children) { for (const child of node.children) {
if (child.parentSlot === 'motif' && child.slot === 'stem_note') if (child.parentSlot === 'line' && child.slot === 'stem_note')
m.stemNotes.push(buildStemNote(child)); m.stemNotes.push(buildStemNote(child));
} }
m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch)); m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch));

View File

@ -193,8 +193,11 @@ export const PaneFO = {
fields: [ fields: [
{ key: 'tick', value: node.tick, editable: false }, { key: 'tick', value: node.tick, editable: false },
{ key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false }, { key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false },
node.motifs?.length
? { key: 'motifs', value: node.motifs.map(m => m.chord ? `${m.label}(${m.chord})` : m.label).join(', '), editable: false }
: null,
...unknownPropFields(node), ...unknownPropFields(node),
], ].filter(Boolean),
onChange: null, onChange: null,
}), }),
); );

View File

@ -434,10 +434,11 @@ section('Motif parsing — dynamic motif');
const DYN_MOTIF = `00 bar '001P1L1M1' const DYN_MOTIF = `00 bar '001P1L1M1'
01 bar.voice 'pi' 01 bar.voice 'pi'
02 voice.motif label='oct' 02 voice.motif label='oct'
03 motif.stem_note pitch=0 03 line.stem_note pitch=0
04 chain.clause 0 repeat=1 04 stem_note.chain _tmp_string='oo'
05 clause.note letter='o' shift=0 length=1 netlength=1 05 chain.clause 0 repeat=1
05 clause.note letter='o' shift=12 netlength=1 length=1 06 seq.note letter='o' shift=0 length=1 netlength=1
06 seq.note letter='o' shift=12 netlength=1 length=1
`; `;
const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0]; const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0];
const dynVoice = dynBar.voices['pi']; const dynVoice = dynBar.voices['pi'];
@ -452,9 +453,10 @@ section('Motif parsing — static motif');
const STAT_MOTIF = `00 bar '001P1L1M1' const STAT_MOTIF = `00 bar '001P1L1M1'
01 bar.voice 'pi' 01 bar.voice 'pi'
02 voice.motif label='cadence' 02 voice.motif label='cadence'
03 motif.stem_note pitch='C4' 03 line.stem_note pitch='C4'
04 chain.clause 0 04 stem_note.chain _tmp_string='o'
05 clause.note letter='o' shift=0 length=1 netlength=1 05 chain.clause 0
06 seq.note letter='o' shift=0 length=1 netlength=1
`; `;
const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi']; const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi'];
ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true); ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true);
@ -464,14 +466,15 @@ section('Motif parsing — pause and stack in clause');
const PS_FIXTURE = `00 bar '001P1L1M1' const PS_FIXTURE = `00 bar '001P1L1M1'
01 bar.voice 'pi' 01 bar.voice 'pi'
02 voice.offset tick=0 02 voice.offset tick=0
03 offset.stem_note pitch='C4' 03 line.stem_note pitch='C4'
04 chain.clause 0 04 stem_note.chain _tmp_string='o.oe'
05 clause.note letter='o' shift=0 length=1 netlength=1 05 chain.clause 0
05 clause.pause length=1 06 seq.note letter='o' shift=0 length=1 netlength=1
04 chain.clause 1 06 seq.pause length=1
05 clause.stack length=2 netlength=2 05 chain.clause 1
06 stack.note letter='o' shift=0 06 seq.stack length=2 netlength=2
06 stack.note letter='e' shift=0 07 stack.note letter='o' shift=0
07 stack.note letter='e' shift=0
`; `;
const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi']; const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi'];
const psSn = psVoice.offsets[0].stemNotes[0]; const psSn = psVoice.offsets[0].stemNotes[0];
@ -484,13 +487,14 @@ section('Motif parsing — nested chain ignored gracefully');
const NC_FIXTURE = `00 bar '001P1L1M1' const NC_FIXTURE = `00 bar '001P1L1M1'
01 bar.voice 'pi' 01 bar.voice 'pi'
02 voice.offset tick=0 02 voice.offset tick=0
03 offset.stem_note pitch='C4' 03 line.stem_note pitch='C4'
04 chain.clause 0 04 stem_note.chain _tmp_string='o(...)'
05 clause.note letter='o' shift=0 length=1 netlength=1 05 chain.clause 0
05 clause.chain length=2 06 seq.note letter='o' shift=0 length=1 netlength=1
06 chain.clause 0 06 seq.chain length=2
07 clause.stack length=1 netlength=1 07 chain.clause 0
08 stack.note letter='o' shift=0 08 seq.stack length=1 netlength=1
09 stack.note letter='o' shift=0
`; `;
const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[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 1 top-level clause', ncSn.clauses.length === 1);
@ -501,8 +505,8 @@ section('Depth-jump guard (abort)');
const DJ_FIXTURE = `00 bar '001P1L1M1' const DJ_FIXTURE = `00 bar '001P1L1M1'
01 bar.voice 'pi' 01 bar.voice 'pi'
02 voice.motif label='oct' 02 voice.motif label='oct'
03 motif.stem_note pitch='C4' 03 line.stem_note pitch='C4'
06 chain.clause 0 07 chain.clause 0
`; `;
let djThrew = false; let djThrew = false;
try { parseAstLog(DJ_FIXTURE); } catch (e) { djThrew = true; } try { parseAstLog(DJ_FIXTURE); } catch (e) { djThrew = true; }
@ -516,6 +520,56 @@ let nsThrew = false;
try { parseAstLog(NS_FIXTURE); } catch (e) { nsThrew = true; } try { parseAstLog(NS_FIXTURE); } catch (e) { nsThrew = true; }
ok('missing slot throws', nsThrew); ok('missing slot throws', nsThrew);
section('line.motif invocation at offset');
const LM_FIXTURE = `00 bar '001P1L1M1'
01 bar.voice 'pi'
02 voice.offset tick=0
03 line.stem_note pitch='C4'
04 stem_note.chain _tmp_string='o'
05 chain.clause 0
06 seq.note letter='o' shift=0 length=1 netlength=1
03 line.motif 'coct'
03 line.motif 'oct' chord='C2'
`;
const lmOffset = buildModel(parseAstLog(LM_FIXTURE)).bars[0].voices['pi'].offsets[0];
ok('line.motif: offset has 1 stem note', lmOffset.stemNotes.length === 1);
ok('line.motif: offset.motifs has 2 entries', lmOffset.motifs.length === 2);
ok('line.motif: first motif label', lmOffset.motifs[0].label === 'coct');
ok('line.motif: second motif label', lmOffset.motifs[1].label === 'oct');
ok('line.motif: second motif chord', lmOffset.motifs[1].chord === 'C2');
ok('line.motif: first motif chord null', lmOffset.motifs[0].chord === null);
section('stem_note.write_to and chainText');
const WT_FIXTURE = `00 bar '001P1L1M1'
01 bar.voice 'pi'
02 voice.offset tick=0
03 line.stem_note pitch='C2'
04 stem_note.write_to 'coct'
04 stem_note.chain _tmp_string='o=o+3*4'
05 chain.clause 0 repeat=3
06 seq.note letter='o' shift=0 netlength=1 length=1
06 seq.note letter='o' shift=3 netlength=1 length=1
`;
const wtSn = buildModel(parseAstLog(WT_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
ok('write_to: writeToName', wtSn.writeToName === 'coct');
ok('write_to: chainText from _tmp_string', wtSn.chainText === 'o=o+3*4');
ok('write_to: clause parsed', wtSn.clauses.length === 1);
ok('write_to: clause repeat', wtSn.clauses[0].repeat === 3);
ok('write_to: clause notes', wtSn.clauses[0].notes.length === 2);
section('adjacent prop on stem_note');
const ADJ_FIXTURE = `00 bar '001P1L1M1'
01 bar.voice 'pi'
02 voice.offset tick=0
03 line.stem_note pitch='C4'
03 line.stem_note pitch='D4' adjacent=False
03 line.stem_note pitch='E4' adjacent=True
`;
const adjOffset = buildModel(parseAstLog(ADJ_FIXTURE)).bars[0].voices['pi'].offsets[0];
ok('adjacent: null when absent', adjOffset.stemNotes[0].adjacent === null);
ok('adjacent: false when False', adjOffset.stemNotes[1].adjacent === false);
ok('adjacent: true when True', adjOffset.stemNotes[2].adjacent === true);
section('Full fixture integration'); section('Full fixture integration');
ok('fixture parses without error (checked above)', raw && raw.slot === 'root'); ok('fixture parses without error (checked above)', raw && raw.slot === 'root');
ok('fixture has 432 bars', model.bars.length === 432); ok('fixture has 432 bars', model.bars.length === 432);
@ -523,6 +577,10 @@ const voicesWithMotifs = model.bars.flatMap(b => Object.values(b.voices)).filter
ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m))); 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); 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))); ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn)));
const offsetsWithMotifInvocations = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.motifs?.length > 0);
ok('fixture offset motif invocations are objects', offsetsWithMotifInvocations.every(o => o.motifs.every(m => 'label' in m)));
const stemNotesWithChainText = offsetsWithStemNotes.flatMap(o => o.stemNotes).filter(sn => sn.chainText);
ok('fixture stem notes with chain text have clauses', stemNotesWithChainText.every(sn => sn.clauses.length > 0));
// ── Summary ──────────────────────────────────────────────────────────────── // ── Summary ────────────────────────────────────────────────────────────────
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`); console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);