From 9eb4add695f561f2baa09317c69ac68437450aac Mon Sep 17 00:00:00 2001 From: c0dev0id Date: Wed, 24 Jun 2026 13:25:23 +0200 Subject: [PATCH] Make bars navigable and editable with _meta export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/ast-parser.js | 1 + static/components/PaneCP.js | 6 ++- static/components/PaneFO.js | 77 +++++++++++++++++++++++++++-- static/components/PaneSubObjects.js | 9 +++- static/exporter.js | 68 +++++++++++++++++++++++-- 5 files changed, 151 insertions(+), 10 deletions(-) diff --git a/static/ast-parser.js b/static/ast-parser.js index c0988c7..f785233 100644 --- a/static/ast-parser.js +++ b/static/ast-parser.js @@ -328,6 +328,7 @@ function buildBar(node) { const bar = { type: 'bar', id: node.positionals[0] ?? '', + isDirty: false, stressor: null, tempoShape: null, tempoLevels: null, diff --git a/static/components/PaneCP.js b/static/components/PaneCP.js index 8f158fb..b35ac21 100644 --- a/static/components/PaneCP.js +++ b/static/components/PaneCP.js @@ -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) { diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js index 2394dca..a61311e 100644 --- a/static/components/PaneFO.js +++ b/static/components/PaneFO.js @@ -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' }, diff --git a/static/components/PaneSubObjects.js b/static/components/PaneSubObjects.js index b9dfb89..70b0454 100644 --- a/static/components/PaneSubObjects.js +++ b/static/components/PaneSubObjects.js @@ -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 []; diff --git a/static/exporter.js b/static/exporter.js index ee1f09b..10c0724 100644 --- a/static/exporter.js +++ b/static/exporter.js @@ -134,11 +134,11 @@ export function exportInstrument(instr) { } // ── Score patch ──────────────────────────────────────────────────────────── -// Replace dirty instrument blocks in rawScoreText with RFC-serialized output. -// Non-dirty instruments are left verbatim. +// Replace dirty instrument blocks and dirty bar _meta blocks. +// Voice note content in bar documents is left verbatim. -export function patchScore(rawScoreText, instruments) { - const lines = rawScoreText.split('\n'); +function patchInstrumentHeader(text, instruments) { + const lines = text.split('\n'); const result = []; const instrMap = {}; for (const instr of instruments) { @@ -149,7 +149,6 @@ export function patchScore(rawScoreText, instruments) { let i = 0; while (i < lines.length) { const line = lines[i]; - // RFC §4.4: "instrument NAME:" — strip optional quotes around name const m = line.match(/^instrument\s+(.+?)\s*:/); if (m) { const rawName = m[1].replace(/^'|'$/g, ''); @@ -171,3 +170,62 @@ export function patchScore(rawScoreText, instruments) { return result.join('\n'); } + +function patchBarMeta(doc, bar) { + const props = []; + if (bar.stressor?.groups?.length) + props.push(` stress_pattern: ${bar.stressor.groups.map(g => g.join(',')).join(';')}`); + if (bar.tempoLevels != null) + props.push(` beats_per_minute: ${bar.tempoLevels}`); + const ub = serializeShape(bar.upperStressBound); + if (ub) props.push(` upper_stress_bound: ${ub}`); + const lb = serializeShape(bar.lowerStressBound); + if (lb) props.push(` lower_stress_bound: ${lb}`); + if (bar.tempoShape) { const ts = serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); } + + const lines = doc.split('\n'); + const out = []; + let i = 0; + let replaced = false; + + while (i < lines.length) { + if (lines[i] === '_meta:') { + replaced = true; + i++; + while (i < lines.length && lines[i].startsWith(' ')) i++; + if (props.length) { out.push('_meta:'); out.push(...props); } + } else { + out.push(lines[i]); + i++; + } + } + + if (!replaced && props.length) { + const idIdx = out.findIndex(l => /^_id:/.test(l)); + if (idIdx !== -1) out.splice(idIdx + 1, 0, '_meta:', ...props); + } + + return out.join('\n'); +} + +export function patchScore(rawScoreText, instruments, bars = []) { + const SEP = '\n---\n'; + const [header, ...barDocs] = rawScoreText.split(SEP); + + const patchedHeader = patchInstrumentHeader(header, instruments); + + if (!barDocs.length) return patchedHeader; + + const barMap = {}; + for (const bar of bars) barMap[bar.id] = bar; + + const patchedBarDocs = barDocs.map(doc => { + const m = doc.match(/^_id:\s*(\S+)/m); + if (!m) return doc; + const bar = barMap[m[1]]; + if (!bar?.isDirty) return doc; + return patchBarMeta(doc, bar); + }); + + return [patchedHeader, ...patchedBarDocs].join(SEP); +}