Make bars navigable and editable with _meta export

Navigation:
- SubObjects: bar → voice (with chevron when offsets exist) → offsets
- PaneCP shortView handles voice and offset types

FO pane:
- Bar: editable beats_per_minute, stress_pattern (serialized as
  groups string), upper/lower stress bounds and tempo shape via
  ShapeEditor; sets bar.isDirty on change
- Voice: read-only name, articles, motifs, offset count
- Offset: read-only tick, stem notes and clusters as label strings
  (note re-serialization deferred)

Export:
- exporter.patchScore() now accepts optional bars[] parameter
- Splits rawScoreText by \n---\n; instruments patched in header,
  dirty bar _meta blocks regenerated, voice note lines kept verbatim
- ast-parser buildBar() adds isDirty:false
- PaneCP passes scoreModel.bars to patchScore
This commit is contained in:
c0dev0id
2026-06-24 13:25:23 +02:00
parent 338ea5be49
commit 9eb4add695
5 changed files with 151 additions and 10 deletions

View File

@@ -26,6 +26,10 @@ function shortView(node) {
return { typeTag: 'label', label: node.label ?? '(no label)', meta: [] };
case 'bar':
return { typeTag: 'bar', label: node.id, meta: [] };
case 'voice':
return { typeTag: 'voice', label: node.name, meta: [] };
case 'offset':
return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] };
default:
return { typeTag: node.type, label: node.type, meta: [] };
}
@@ -43,7 +47,7 @@ export const PaneCP = {
try {
const raw = await fetchScoreText(props.store.credentials);
props.store.rawScoreText = raw;
const patched = patchScore(raw, props.store.scoreModel.instruments);
const patched = patchScore(raw, props.store.scoreModel.instruments, props.store.scoreModel.bars);
await putScoreText(patched, props.store.credentials);
props.store.synthesisStatus = { frozen: false, progress: 0 };
} catch (e) {

View File

@@ -1,8 +1,21 @@
import { h, ref } from 'vue';
import { ObjectExtended } from './ObjectExtended.js';
import { EnvelopeEditor } from './EnvelopeEditor.js';
import { ShapeEditor } from './ShapeEditor.js';
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
function stressorToString(s) {
if (!s?.groups?.length) return '';
return s.groups.map(g => g.join(',')).join(';');
}
function parseStressor(str) {
const groups = str.split(';').map(seg =>
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
).filter(g => g.length > 0);
return groups.length ? { type: 'stressor', groups } : null;
}
function scoreInfoFields(info) {
return [
{ key: 'title', value: info?.title ?? '', editable: true },
@@ -136,11 +149,69 @@ export const PaneFO = {
: null,
);
} else if (node.type === 'bar') {
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
children.push(
h('h4', { style: 'margin:0 0 0.5rem' }, `Bar: ${node.id}`),
h(ObjectExtended, { fields: [
{ key: 'id', value: node.id, editable: false },
], onChange: null }),
h(ObjectExtended, {
fields: [
{ key: 'id', value: node.id, editable: false },
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
],
onChange: ({ key, value }) => {
if (key === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value;
if (key === 'stress_pattern') node.stressor = parseStressor(value);
markBarDirty();
},
}),
node.upperStressBound ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Upper stress bound'),
h(ShapeEditor, { shape: node.upperStressBound, onChange: markBarDirty }),
]) : null,
node.lowerStressBound ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Lower stress bound'),
h(ShapeEditor, { shape: node.lowerStressBound, onChange: markBarDirty }),
]) : null,
node.tempoShape ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Tempo shape'),
h(ShapeEditor, { shape: node.tempoShape, onChange: markBarDirty }),
]) : null,
);
} else if (node.type === 'voice') {
children.push(
h('h4', { style: 'margin:0 0 0.5rem' }, `Voice: ${node.name}`),
h(ObjectExtended, {
fields: [
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
{ key: 'motifs', value: node.motifs.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}]`;
children.push(
h('h4', { style: 'margin:0 0 0.5rem' }, `Tick: ${node.tick}`),
h(ObjectExtended, {
fields: [{ key: 'tick', value: node.tick, editable: false }],
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) =>
h('li', { class: 'se-object-item', key: i },
h('span', { class: 'se-object-label' }, noteStr(n))))),
]) : 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) =>
h('li', { class: 'se-object-item', key: i },
h('span', { class: 'se-object-label' }, clusterStr(c))))),
]) : null,
);
} else {
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },

View File

@@ -51,7 +51,14 @@ export const PaneSubObjects = {
}
if (node.type === 'bar') {
return Object.entries(node.voices).map(([name, v]) => ({
kind: 'voice', node: v, label: name,
kind: 'voice', node: v, label: name, hasChildren: v.offsets.length > 0,
}));
}
if (node.type === 'voice') {
return node.offsets.map((o, idx) => ({
kind: 'offset', node: o,
label: `tick ${o.tick ?? idx}`,
hasChildren: false,
}));
}
return [];