Add motif/stem_note display and offset note hierarchy

- parseAstLog: abort on depth jump >1 or missing slot (depth>0)
- buildStemNote: full clause tree (notes/pauses/stacks), editable fields
- buildClause: replaces buildCluster; filters nested clause.chain silently
- buildMotif: builds from voice.motif; sets isStatic
- buildOffset: uses buildStemNote; drops old clusters/chains arrays
- buildVoice: motifs are now objects, not label strings
- PaneSubObjects: static motifs before offsets; motif/offset→stem_note drill-down
- PaneFO: motif + stem_note FO cases; unknownProps display helper
- PaneCP: motif + stem_note shortView cases
- test-parser.mjs: 21 new synthetic tests for motif/clause/stack/guards
This commit is contained in:
c0dev0id
2026-06-27 16:14:56 +02:00
parent 3cd66f1e10
commit b887341df4
5 changed files with 233 additions and 66 deletions

View File

@@ -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) {

View File

@@ -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: [] };
}

View File

@@ -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' },

View File

@@ -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 [];