From 7ffa0a9d2da6515743ee7f80dfb485513fd170ad Mon Sep 17 00:00:00 2001 From: c0dev0id Date: Sat, 11 Jul 2026 12:07:05 +0200 Subject: [PATCH] phases 4-6: article system, stem note fields, edit-state cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — remove edit-state flags from domain objects: - ast-parser.js: drop isDirty from buildInstrument, buildBar, buildStemNote - store.js: add resetEditState() - PaneCP.js: use resetEditState() on import; drop flags arg from patchScore - PaneFO.js: isDirty -> _modified for linked instruments; no per-node dirty tracking - PaneSubObjects.js: _spliceNode helper; deletion via splice; _isNew on new bars - exporter.js: on-demand traversal; absence from model means deleted Phase 5 — article parser and exporter: - ast-parser.js: _buildArticleEntry replaces _mergeArticleEntry; stage.article and voice.article dispatch; article properties carry scope and overwritten flag - exporter.js: _buildArticlesBlock emits -important: list for overwrites scope Phase 6 — stem note dead fields: - ast-parser.js: parse weight and articulatory on stem notes; flatten article.definite properties onto stem note model sibling to pitch/chain --- static/ast-parser.js | 68 +++++++++------- static/components/PaneCP.js | 4 +- static/components/PaneFO.js | 17 ++-- static/components/PaneSubObjects.js | 31 ++++---- static/exporter.js | 53 +++++++------ static/store.js | 6 ++ test-parser.mjs | 116 +++++++++++++++++----------- 7 files changed, 166 insertions(+), 129 deletions(-) diff --git a/static/ast-parser.js b/static/ast-parser.js index 57528df..623899d 100644 --- a/static/ast-parser.js +++ b/static/ast-parser.js @@ -109,14 +109,24 @@ export function buildModel(rawTree) { case 'stage.cone': score.stageCone = { type: 'stage_cone', ...node.props }; break; - case 'stage.voice': - score.stageVoices.push({ + case 'stage.article': + score.articles.push(_buildArticleEntry(node)); + break; + case 'stage.voice': { + const sv = { type: 'stage_voice', name: node.positionals[0], direction: node.props.direction, distance: node.props.distance, - }); + articles: [], + }; + for (const child of node.children) { + if (child.parentSlot === 'voice' && child.slot === 'article') + sv.articles.push(_buildArticleEntry(child)); + } + score.stageVoices.push(sv); break; + } case 'instrument': score.instruments.push(_buildInstrument(node)); break; @@ -124,31 +134,27 @@ export function buildModel(rawTree) { score.bars.push(_buildBar(node)); break; default: - if (node.parentSlot === 'articles') { - _mergeArticleEntry(score, node); - } else { - score[node.slot] = _buildGeneric(node); - } + score[node.slot] = _buildGeneric(node); } } return score; } -// Articles are keyed by label. The AST emits one line per (label, subtype) -// — e.g. `articles.defaults 'f'` and (future) `articles.overwrites 'f'`. -// We merge them into one entry per label whose properties carry the scope -// of their originating subtype, so the UI can toggle scope per property. -function _mergeArticleEntry(score, node) { - const name = node.positionals[0]; - let entry = score.articles.find(a => a.name === name); - if (!entry) { - entry = { type: 'article', name, properties: [] }; - score.articles.push(entry); - } - const scope = node.slot; // 'defaults' | 'overwrites' | future subtype - for (const [key, value] of Object.entries(node.props)) { - entry.properties.push({ name: key, value, scope }); +function _buildArticleEntry(node) { + const entry = { type: 'article', name: node.positionals[0], properties: [] }; + for (const child of node.children) { + if (child.parentSlot !== 'article') continue; + if (child.slot === 'defaults' || child.slot === 'definite') { + const raw = child.props.constant ?? child.props.stacked ?? child.props.static; + const shapeChild = child.children.find(c => c.slot === 'shape'); + const value = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null); + entry.properties.push({ name: child.positionals[0], value, scope: child.slot, overwritten: child.props.overwritten ?? false }); + } else if (child.slot === 'overwrites') { + for (const propName of child.positionals) { + entry.properties.push({ name: propName, value: null, scope: 'overwrites' }); + } + } } return entry; } @@ -176,7 +182,6 @@ function _buildInstrument(node) { name: node.positionals[0], notChangedSince: node.props.NOT_CHANGED_SINCE ?? null, isLinked: (node.positionals[0] ?? '').includes('/'), - isDirty: false, variations: [], basicProperties: null, volumes: null, @@ -339,7 +344,6 @@ function _buildBar(node) { const bar = { type: 'bar', id: node.positionals[0] ?? '', - isDirty: false, stressor: null, tempoShape: null, tempoLevels: null, @@ -413,7 +417,7 @@ function _buildVoice(node) { voice.offsets.push(_buildOffset(child)); break; case 'voice.article': - voice.articles.push(child.positionals[0]); + voice.articles.push(_buildArticleEntry(child)); break; case 'voice.motif': voice.motifs.push(_buildMotif(child)); @@ -444,9 +448,17 @@ function _buildOffset(node) { } function _buildStemNote(node) { - const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']); + const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent', 'weight', 'articulatory']); 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'); + const definiteProps = {}; + for (const child of node.children) { + if (child.parentSlot === 'article' && child.slot === 'definite') { + const raw = child.props.constant ?? child.props.stacked ?? child.props.static; + const shapeChild = child.children.find(c => c.slot === 'shape'); + definiteProps[child.positionals[0]] = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null); + } + } return { type: 'stem_note', pitch: node.props.pitch, @@ -454,13 +466,13 @@ function _buildStemNote(node) { adjacent: node.props.adjacent ?? null, adjStress: node.props.adj_stress ?? null, length: null, - weight: null, + weight: node.props.weight ?? null, chainText: chainNode?.props._tmp_string ?? '', writeToName: writeToNode?.positionals[0] ?? null, clauses: (chainNode?.children ?? []) .filter(c => c.parentSlot === 'chain' && c.slot === 'clause') .map(_buildClause), - isDirty: false, + ...definiteProps, unknownProps: _collectUnknownProps(node.props, KNOWN), }; } diff --git a/static/components/PaneCP.js b/static/components/PaneCP.js index 6e16d1c..08d8105 100644 --- a/static/components/PaneCP.js +++ b/static/components/PaneCP.js @@ -32,7 +32,7 @@ export const PaneCP = { try { const text = await fetchAstLog(); props.store.scoreModel = buildModel(parseAstLog(text)); - props.store.exportLog = []; + props.store.resetEditState(); } catch (e) { importError.value = e.message; } finally { @@ -49,9 +49,7 @@ export const PaneCP = { const model = props.store.scoreModel; const { text: patched, log } = patchScore( raw, model.instruments, model.bars, model.info, model.articles ?? [], - { articlesModified: !!model.articlesModified }, ); - model.articlesModified = false; props.store.exportLog = log; await putScoreText(patched); props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 }; diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js index cf7dccb..10d8bdc 100644 --- a/static/components/PaneFO.js +++ b/static/components/PaneFO.js @@ -61,10 +61,10 @@ export const PaneFO = { // Guard: first edit to a linked instrument triggers embed-or-discard before committing. function makeChangeHandler(instr) { return (info) => { - if (instr.isLinked && !instr.isDirty) { + if (instr.isLinked && !instr._modified) { pendingEdit.value = { instr, undo: info?.undo }; } else { - instr.isDirty = true; + instr._modified = true; props.store.markDirty(); } }; @@ -73,7 +73,7 @@ export const PaneFO = { function embedInstrument(instr) { instr.name = instr.name.split('/').pop(); instr.isLinked = false; - instr.isDirty = true; + instr._modified = true; pendingEdit.value = null; props.store.markDirty(); } @@ -152,7 +152,7 @@ export const PaneFO = { : null, ); } else if (node.type === 'article') { - const markDirty = () => { node.isDirty = true; props.store.markDirty(); }; + const markDirty = () => { props.store.markDirty(); }; const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0'; children.push( h('h4', H4, `Article: ${node.name}`), @@ -209,12 +209,12 @@ export const PaneFO = { ]), ); } else if (node.type === 'bar') { - const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); }; + const markBarDirty = () => { props.store.markDirty(); }; children.push( h('h4', H4, `Bar: ${node.id}`), h(ObjectExtended, { fields: [ - { key: 'id', value: node.id, editable: node.isNew ?? false }, + { key: 'id', value: node.id, editable: node._isNew ?? false }, { key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' }, { key: 'stress_pattern', value: stressorToString(node.stressor), editable: true }, ], @@ -234,7 +234,7 @@ export const PaneFO = { h('h4', H4, `Voice: ${node.name}`), h(ObjectExtended, { fields: [ - { key: 'articles', value: node.articles.join(', ') || '—', editable: false }, + { key: 'articles', value: node.articles.map(a => a.name).join(', ') || '—', editable: false }, { key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false }, { key: 'offsets', value: String(node.offsets.length), editable: false }, ], @@ -273,10 +273,7 @@ export const PaneFO = { }), ); } 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( diff --git a/static/components/PaneSubObjects.js b/static/components/PaneSubObjects.js index a1b7b25..ec11672 100644 --- a/static/components/PaneSubObjects.js +++ b/static/components/PaneSubObjects.js @@ -23,38 +23,39 @@ export const PaneSubObjects = { return fp.length ? fp[fp.length - 1] : props.store.scoreModel; } + function _spliceNode(arr, node) { + const idx = arr.indexOf(node); + if (idx !== -1) { arr.splice(idx, 1); props.store.markDirty(); } + } + function deleteItem(kind, node) { const store = props.store; const model = store.scoreModel; if (!model) return; if (kind === 'instrument') { - const idx = model.instruments.indexOf(node); - if (idx !== -1) { model.instruments.splice(idx, 1); store.markDirty(); } + _spliceNode(model.instruments, node); } else if (kind === 'articles') { - const idx = (model.articles ?? []).indexOf(node); - if (idx !== -1) { model.articles.splice(idx, 1); model.articlesModified = true; store.markDirty(); } + _spliceNode(model.articles, node); } else if (kind === 'bar') { - node.deleted = true; - node.isDirty = true; - store.markDirty(); + _spliceNode(model.bars, node); } else if (kind === 'variation') { for (const instr of model.instruments) { const idx = instr.variations.indexOf(node); - if (idx !== -1) { instr.variations.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; } + if (idx !== -1) { instr.variations.splice(idx, 1); instr._modified = true; store.markDirty(); return; } for (const v of instr.variations) { const sidx = v.subvariations.indexOf(node); - if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr.isDirty = true; store.markDirty(); return; } + if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr._modified = true; store.markDirty(); return; } } } } else if (kind === 'label_spec') { for (const instr of model.instruments) { for (const v of instr.variations) { const idx = v.labelSpecs.indexOf(node); - if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; } + if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr._modified = true; store.markDirty(); return; } for (const sv of v.subvariations) { const idx2 = sv.labelSpecs.indexOf(node); - if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr.isDirty = true; store.markDirty(); return; } + if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr._modified = true; store.markDirty(); return; } } } } @@ -69,11 +70,11 @@ export const PaneSubObjects = { if (afterId) { newId = _incrementId(afterId); } else { - const liveBars = (model.bars ?? []).filter(b => !b.deleted); + const liveBars = model.bars ?? []; newId = liveBars.length ? _incrementId(liveBars[liveBars.length - 1].id) : 'bar001'; } const newBar = { - type: 'bar', id: newId, isDirty: true, isNew: true, + type: 'bar', id: newId, _isNew: true, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null, voices: {}, @@ -95,11 +96,9 @@ export const PaneSubObjects = { if (!items.length && props.kind !== 'bar') return h('div', null, h('em', null, 'No sub-objects')); if (props.kind === 'bar') { - const visibleItems = items.filter(item => !item.node.deleted); - const barGroups = []; const seen = new Map(); - for (const item of visibleItems) { + for (const item of items) { const key = _barGroupKey(item.label); if (!seen.has(key)) { const g = { key, items: [] }; diff --git a/static/exporter.js b/static/exporter.js index ebb2e26..23535d8 100644 --- a/static/exporter.js +++ b/static/exporter.js @@ -133,23 +133,30 @@ function _serializeArticleValue(v) { function _buildArticlesBlock(articles) { const body = articles.flatMap(art => { - const props = (art.properties ?? []).filter(p => p.name); - if (!props.length) return []; - return [` ${art.name}:`, ...props.map(p => ` ${p.name}: ${_serializeArticleValue(p.value)}`)]; + const defaults = (art.properties ?? []).filter(p => p.scope !== 'overwrites' && p.name); + const overwrites = (art.properties ?? []).filter(p => p.scope === 'overwrites' && p.name); + if (!defaults.length && !overwrites.length) return []; + const lines = [` ${art.name}:`]; + defaults.forEach(p => lines.push(` ${p.name}: ${_serializeArticleValue(p.value)}`)); + if (overwrites.length) { + lines.push(` -important:`); + overwrites.forEach(p => lines.push(` - ${p.name}`)); + } + return lines; }); return body.length ? ['articles:', ...body].join('\n') : null; } function _patchArticles(text, articles) { const newBlock = _buildArticlesBlock(articles); - if (!newBlock) return text; const lines = text.split('\n'); const artIdx = lines.findIndex(l => /^articles\s*:/.test(l)); if (artIdx !== -1) { let end = artIdx + 1; while (end < lines.length && (lines[end] === '' || lines[end].startsWith(' ') || lines[end].startsWith('\t'))) end++; - lines.splice(artIdx, end - artIdx, newBlock); - } else { + if (newBlock) lines.splice(artIdx, end - artIdx, newBlock); + else lines.splice(artIdx, end - artIdx); + } else if (newBlock) { const instrIdx = lines.findIndex(l => /^instrument\s/.test(l)); const insertAt = instrIdx !== -1 ? instrIdx : lines.length; lines.splice(insertAt, 0, newBlock, ''); @@ -196,17 +203,18 @@ function _patchInstrumentHeader(text, instruments) { if (m) { const rawName = m[1].replace(/^'|'$/g, ''); const instr = instrMap[rawName]; - if (instr && instr.deleted) { + if (!instr) { + // not in model → deleted; skip block i++; while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; - } else if (instr && instr.isDirty) { + } else if (instr.isLinked && !instr._modified) { + result.push(line); + i++; + } else { i++; while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; result.push(exportInstrument(instr)); result.push(''); - } else { - result.push(line); - i++; } } else { result.push(line); @@ -263,42 +271,37 @@ function _buildNewBarDoc(bar) { return lines.join('\n'); } -export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = [], flags = {}) { +export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = []) { const log = []; const SEP = '\n---\n'; const [header, ...barDocs] = rawScoreText.split(SEP); let patchedHeader = _patchMetadata(header, info); - const dirtyArticles = articles.filter(a => a.isDirty); - if (dirtyArticles.length || flags.articlesModified) { + if (articles.length) { patchedHeader = _patchArticles(patchedHeader, articles); - dirtyArticles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` })); + articles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` })); } patchedHeader = _patchInstrumentHeader(patchedHeader, instruments); instruments.forEach(i => { - if (i.deleted) log.push({ level: 'changed', path: `instrument / ${i.name} (deleted)` }); - else if (i.isDirty) log.push({ level: 'changed', path: `instrument / ${i.name}` }); + if (!(i.isLinked && !i._modified)) log.push({ level: 'changed', path: `instrument / ${i.name}` }); }); const barMap = Object.fromEntries(bars.map(b => [b.id, b])); - const newBars = bars.filter(b => b.isNew && !b.deleted); + const newBars = bars.filter(b => b._isNew); if (!barDocs.length) { newBars.forEach(b => log.push({ level: 'changed', path: `bar / ${b.id}` })); return { text: [patchedHeader, ...newBars.map(_buildNewBarDoc)].join(SEP), log }; } - let passedThrough = 0; const patchedBarDocs = []; barDocs.forEach(doc => { const m = doc.match(/^_id:\s*(\S+)/m); - if (!m) { passedThrough++; patchedBarDocs.push(doc); return; } + if (!m) { patchedBarDocs.push(doc); return; } const bar = barMap[m[1]]; - if (!bar) { passedThrough++; patchedBarDocs.push(doc); return; } - if (bar.deleted) return; - if (!bar.isDirty) { passedThrough++; patchedBarDocs.push(doc); return; } + if (!bar) return; // not in model → deleted log.push({ level: 'changed', path: `bar / ${bar.id}` }); patchedBarDocs.push(_patchBarMeta(doc, bar)); }); @@ -308,9 +311,5 @@ export function patchScore(rawScoreText, instruments, bars = [], info = null, ar log.push({ level: 'changed', path: `bar / ${bar.id}` }); }); - if (passedThrough > 0) { - log.push({ level: 'info', message: `${passedThrough} bar document${passedThrough !== 1 ? 's' : ''} passed through unchanged` }); - } - return { text: [patchedHeader, ...patchedBarDocs].join(SEP), log }; } diff --git a/static/store.js b/static/store.js index 370ad4f..6edd402 100644 --- a/static/store.js +++ b/static/store.js @@ -25,4 +25,10 @@ export const store = reactive({ markDirty() { this.isDirty = true; }, + + resetEditState() { + this.isDirty = false; + this.exportLog = []; + this.focusPath = []; + }, }); diff --git a/test-parser.mjs b/test-parser.mjs index 44b2411..86baa9f 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -55,9 +55,10 @@ ok('articles[0].properties[]', Array.isArray(model.articles[0].properties) const fProp = model.articles[0].properties.find(p => p.name === 'add_stress'); ok('articles[0] add_stress prop', fProp && fProp.value === 3 && fProp.scope === 'defaults'); -// Merge across subtypes: same label, different subtypes → one entry, multi-scope props -const MERGE_FIXTURE = `01 articles.defaults 'g' add_stress=2 -01 articles.overwrites 'g' pitch_bend=0.05 +// Article slot structure: stage.article with article.defaults / article.overwrites children +const MERGE_FIXTURE = `01 stage.article 'g' +02 article.defaults 'add_stress' constant=2 +02 article.overwrites 'pitch_bend' `; const mergeRoot = parseAstLog('00 tuning base=\'x\'\n' + MERGE_FIXTURE); const mergeModel = buildModel(mergeRoot); @@ -67,7 +68,33 @@ ok('merge: 2 properties', mergeModel.articles[0].properties.length const gDef = mergeModel.articles[0].properties.find(p => p.scope === 'defaults'); const gOver = mergeModel.articles[0].properties.find(p => p.scope === 'overwrites'); ok('merge: default scope present', gDef && gDef.name === 'add_stress' && gDef.value === 2); -ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === 0.05); +ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === null); + +section('article.definite on stem_note'); +const DEF_ART = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.offset tick=0 +03 line.stem_note pitch='G5' articulatory='tailed' +04 article.definite 'tailed' constant=3 +04 stem_note.chain _tmp_string='o' +05 chain.clause 0 +06 seq.note letters='o' shift=0 length=1 netlength=1 +`; +const defArtModel = buildModel(parseAstLog(DEF_ART)); +const defSN = defArtModel.bars[0].voices['pi'].offsets[0].stemNotes[0]; +ok('definite prop flattened onto stem_note', defSN.tailed === 3); +ok('articulatory not in unknownProps', defSN.unknownProps?.articulatory === undefined); + +section('stem_note weight'); +const WEIGHT_SN = `00 bar '001P1L1M1' +01 bar.voice 'pi' +02 voice.offset tick=0 +03 line.stem_note pitch='C4' weight=0.8 +`; +const weightModel = buildModel(parseAstLog(WEIGHT_SN)); +const weightSN = weightModel.bars[0].voices['pi'].offsets[0].stemNotes[0]; +ok('weight parsed', weightSN.weight === 0.8); +ok('weight null when absent', defSN.weight === null); // ── Bar IDs ──────────────────────────────────────────────────────────────── // Bar IDs are opaque auto-increment strings; only the raw id string matters. @@ -230,8 +257,8 @@ const SCORE_FIXTURE = new URL('./fixtures/pathetique.spls', import.meta.url); const rawScore = readFileSync(SCORE_FIXTURE, 'utf8'); // pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded. -// Mark only alpha as dirty, verify ki is preserved verbatim. -const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' })); +// All non-linked instruments are always re-serialized; linked+unmodified are passed through. +const patchInstruments = model.instruments.map(i => ({ ...i })); const { text: patched, log: patchLog } = patchScore(rawScore, patchInstruments); const patchedLines = patched.split('\n'); @@ -239,16 +266,13 @@ ok('patched score still has instrument alpha:', patchedLines.some(l => /^instrum ok('patched score still has instrument ki:', patchedLines.some(l => /^instrument\s+ki\s*:/.test(l))); ok('patched score alpha block contains character:', patchedLines.some(l => l.trim() === 'character:')); ok('patchScore log records alpha as changed', patchLog.some(e => e.level === 'changed' && e.path === 'instrument / alpha')); -ok('patchScore log does not record ki (not dirty)', !patchLog.some(e => e.path?.includes('ki'))); +ok('patchScore log records ki as changed', patchLog.some(e => e.path?.includes('ki'))); -// Ki is clean — its block must appear verbatim (check a unique line from the original) -const kiOrigLines = rawScore.split('\n').filter(l => l.startsWith('instrument ki:') || (l.startsWith(' ') && rawScore.indexOf('instrument ki:') < rawScore.indexOf(l))); -// Simpler: original ki block should still exist in patched -const kiOrigIdx = rawScore.indexOf('\ninstrument ki:'); -const kiBlock = kiOrigIdx >= 0 ? rawScore.slice(kiOrigIdx + 1, rawScore.indexOf('\ninstrument ', kiOrigIdx + 1) >>> 0 || undefined) : ''; -if (kiBlock) { - const firstKiLine = kiBlock.split('\n')[0]; - ok('ki block preserved verbatim (first line)', patched.includes(firstKiLine)); +// dev/piano is linked and unmodified — it should pass through verbatim +const pianoInstr = patchInstruments.find(i => i.name === 'dev/piano'); +if (pianoInstr) { + ok('dev/piano is linked', pianoInstr.isLinked === true); + ok('dev/piano not in log (linked+unmodified)', !patchLog.some(e => e.path?.includes('dev/piano'))); } // patched score must not be empty and must be shorter or same length as original + alpha export @@ -421,7 +445,6 @@ voice soprano: const dirtyBar = { id: '001P1L1M1', - isDirty: true, stressor: { groups: [[2, 3], [1]] }, tempoLevels: 140, upperStressBound: null, @@ -430,8 +453,7 @@ const dirtyBar = { }; const cleanBar = { id: '001P1L1M2', - isDirty: false, - stressor: null, tempoLevels: null, + stressor: null, tempoLevels: 100, upperStressBound: null, lowerStressBound: null, tempoShape: null, }; const { text: barPatched, log: barLog } = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]); @@ -439,11 +461,15 @@ const barPatchedLines = barPatched.split('\n'); ok('dirty bar _meta updated with new BPM', barPatched.includes('beats_per_minute: 140')); ok('dirty bar stress_pattern updated', barPatched.includes('stress_pattern: 2,3;1')); ok('dirty bar voice content preserved', barPatched.includes('- C4 4')); -ok('clean bar unchanged', barPatched.includes('beats_per_minute: 100')); +ok('clean bar BPM preserved via model', barPatched.includes('beats_per_minute: 100')); ok('clean bar voice preserved', barPatched.includes('- D4 4')); ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2); ok('bar log records dirty bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M1')); -ok('bar log has pass-through info entry', barLog.some(e => e.level === 'info' && e.message?.includes('passed through'))); +ok('bar log records clean bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M2')); + +// Minimal instrument stub reused across patchScore tests that don't care about instrument content. +const STUB_ALPHA = { name: 'alpha', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }; +const STUB_KI = { name: 'ki', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }; // ── patchScore metadata ──────────────────────────────────────────────────── section('patchScore metadata'); @@ -456,26 +482,26 @@ instrument alpha: A: "1:0,10;1,0" `; const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' }; -const { text: metaPatched } = patchScore(META_SCORE, [], [], updatedInfo); +const { text: metaPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], updatedInfo); ok('existing title replaced', metaPatched.includes('title: New Title')); ok('existing composer replaced', metaPatched.includes('composer: New Composer')); ok('empty value leaves existing line', metaPatched.includes('source: Some Book')); ok('new key prepended', metaPatched.includes('encrypter: Me')); -ok('instrument block untouched', metaPatched.includes('instrument alpha:')); +ok('instrument block present', metaPatched.includes('instrument alpha:')); const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`; -const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' }); +const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [STUB_KI], [], { title: 'Fugue', composer: 'Bach' }); ok('missing title prepended', noTitlePatched.includes('title: Fugue')); ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1); -const { text: nullInfoPatched } = patchScore(META_SCORE, [], [], null); +const { text: nullInfoPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], null); ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title')); // ── patchScore articles ──────────────────────────────────────────────────── section('patchScore articles'); const ART_SCORE = `articles:\n f: { add_stress: 3 }\n\ninstrument alpha:\n character:\n`; -const artModel = [{ name: 'f', isDirty: true, properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }]; -const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [], [], null, artModel); +const artModel = [{ name: 'f', properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }]; +const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [STUB_ALPHA], [], null, artModel); ok('article block replaced', artPatched.includes('articles:')); ok('updated value written', artPatched.includes('add_stress: 4')); ok('old flow-style entry removed', !artPatched.includes('{ add_stress: 3 }')); @@ -483,33 +509,34 @@ ok('instrument line still present', artPatched.includes('instrument alpha:')); ok('article change logged', artLog.some(e => e.level === 'changed' && e.path === 'articles / f')); const ART_SCORE_NO_ART = `instrument alpha:\n character:\n`; -const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [], [], null, artModel); +const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [STUB_ALPHA], [], null, artModel); ok('article block inserted before instrument when missing', artInserted.indexOf('articles:') < artInserted.indexOf('instrument alpha:')); -const artClean = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 3, scope: 'defaults' }] }]; -const { text: artUntouched } = patchScore(ART_SCORE, [], [], null, artClean); -ok('clean articles section left verbatim', artUntouched.includes('{ add_stress: 3 }')); - -// ── patchScore articlesModified flag ────────────────────────────────────── -section('patchScore articlesModified flag'); -const artCleanForFlag = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 99, scope: 'defaults' }] }]; -const { text: artFlagPatched, log: artFlagLog } = patchScore(ART_SCORE, [], [], null, artCleanForFlag, { articlesModified: true }); -ok('articlesModified forces re-serialization', artFlagPatched.includes('add_stress: 99')); -ok('articlesModified: old flow-style replaced', !artFlagPatched.includes('{ add_stress: 3 }')); +// ── patchScore articles with overwrites ─────────────────────────────────── +section('patchScore articles with overwrites'); +const OVW_SCORE = `articles:\n f:\n add_stress: 3\n\ninstrument alpha:\n character:\n`; +const ovwModel = [{ name: 'f', properties: [ + { name: 'add_stress', value: 4, scope: 'defaults' }, + { name: 'add_stress', scope: 'overwrites' }, +]}]; +const { text: ovwPatched } = patchScore(OVW_SCORE, [STUB_ALPHA], [], null, ovwModel); +ok('overwrites emit -important block', ovwPatched.includes('-important:')); +ok('overwrites list entry present', ovwPatched.includes('- add_stress')); // ── patchScore deleted bars ──────────────────────────────────────────────── section('patchScore deleted bars'); -const delBar1 = { id: '001P1L1M1', isDirty: true, deleted: true, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null }; -const delBar2 = { id: '001P1L1M2', isDirty: false, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null }; -const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar1, delBar2]); +const delBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar2]); ok('deleted bar removed from output', !delBarPatched.includes('_id: 001P1L1M1')); ok('non-deleted bar preserved', delBarPatched.includes('_id: 001P1L1M2')); ok('deleted bar reduces document count', (delBarPatched.match(/\n---\n/g) ?? []).length === 1); // ── patchScore new bars ──────────────────────────────────────────────────── section('patchScore new bars'); -const newBarEntry = { id: '001P1L1M3', isDirty: true, isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null }; -const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [newBarEntry]); +const existingBar1 = { id: '001P1L1M1', stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const existingBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: 100, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const newBarEntry = { id: '001P1L1M3', _isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null }; +const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [existingBar1, existingBar2, newBarEntry]); ok('new bar appended to output', newBarPatched.includes('_id: 001P1L1M3')); ok('new bar has _meta with BPM', newBarPatched.includes('beats_per_minute: 120')); ok('new bar appended as extra document', (newBarPatched.match(/\n---\n/g) ?? []).length === 3); @@ -519,13 +546,12 @@ ok('new bar logged as changed', newBarLog.some(e => e.level === 'changed' && e.p section('patchScore deleted instrument'); const delInstrScore = `instrument alpha:\n character:\n A: "1:0,10;1,0"\n\ninstrument ki:\n character:\n A: "1:0,5;1,0"\n`; const delInstrList = [ - { name: 'alpha', deleted: true, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, - { name: 'ki', deleted: false, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, + { name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, ]; const { text: delInstrPatched, log: delInstrLog } = patchScore(delInstrScore, delInstrList); ok('deleted instrument removed from output', !delInstrPatched.includes('instrument alpha:')); ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:')); -ok('deleted instrument logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('alpha') && e.path?.includes('deleted'))); +ok('ki re-serialized and logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('ki'))); // ── Motif parsing ───────────────────────────────────────────────────────── section('Motif parsing — dynamic motif');