From 19c89711bed7513c0ca6febf8997d7246c37bb6b Mon Sep 17 00:00:00 2001 From: c0dev0id Date: Mon, 13 Jul 2026 21:16:02 +0200 Subject: [PATCH] exporter: remove stage voice entries for deleted instruments --- static/exporter.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++ test-parser.mjs | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/static/exporter.js b/static/exporter.js index 06dd2e6..24c4109 100644 --- a/static/exporter.js +++ b/static/exporter.js @@ -246,6 +246,66 @@ function _patchInstrumentHeader(text, instruments) { } +// Remove stage voice entries whose referenced instrument is no longer in the model. +// String form: ` voicename: LEFT|RIGHT DIST [Instrument]` — 3rd token is instrument. +// Mapping form: ` voicename:\n instrument: Name` — look for `instrument:` child. +// When no explicit instrument is present, the voice name is the implicit instrument name. + +function _patchStageSection(text, instruments) { + const instrNames = new Set(instruments.flatMap(i => { + const names = [i.name]; + if (i.name.includes('/')) names.push(i.name.split('/').pop()); + return names; + })); + const lines = text.split('\n'); + const stageIdx = lines.findIndex(l => /^stage\s*:/.test(l)); + if (stageIdx === -1) return text; + + let stageEnd = lines.length; + for (let j = stageIdx + 1; j < lines.length; j++) { + if (lines[j] !== '' && !/^\s/.test(lines[j])) { stageEnd = j; break; } + } + + const stageLines = lines.slice(stageIdx + 1, stageEnd); + const firstIndented = stageLines.find(l => l !== '' && /^\s/.test(l)); + if (!firstIndented) return text; + const voiceIndentLen = firstIndented.match(/^(\s+)/)[1].length; + + // Group stageLines into per-voice entries + const entries = []; + let cur = null; + for (const line of stageLines) { + if (line === '') { if (cur) cur.lines.push(line); continue; } + const indentLen = (line.match(/^(\s+)/) ?? ['', ''])[1].length; + if (indentLen === voiceIndentLen) { + if (cur) entries.push(cur); + const m = line.match(/^\s+(\w[\w./]*)\s*:(.*)/); + cur = m ? { lines: [line], name: m[1], rest: m[2].trim() } : { lines: [line], name: null }; + } else if (cur) { + cur.lines.push(line); + } + } + if (cur) entries.push(cur); + + const keptLines = []; + for (const entry of entries) { + if (!entry.name || entry.name === '_space') { keptLines.push(...entry.lines); continue; } + let instrRef = null; + if (entry.rest) { + const parts = entry.rest.split(/\s+/); + if (parts.length >= 3) instrRef = parts[2]; + } else { + for (const l of entry.lines.slice(1)) { + const im = l.match(/^\s+instrument\s*:\s*(\S+)/); + if (im) { instrRef = im[1]; break; } + } + } + if (instrNames.has(instrRef ?? entry.name)) keptLines.push(...entry.lines); + } + + return [...lines.slice(0, stageIdx + 1), ...keptLines, ...lines.slice(stageEnd)].join('\n'); +} + function _patchBarMeta(doc, bar) { const props = []; const sp = stressorToString(bar.stressor); @@ -304,6 +364,7 @@ export function patchScore(rawScoreText, instruments, bars = [], info = null, ar } patchedHeader = _patchInstrumentHeader(patchedHeader, instruments); + patchedHeader = _patchStageSection(patchedHeader, instruments); instruments.forEach(i => { if (!(i.isLinked && !i._modified)) log.push({ level: 'changed', path: `instrument / ${i.name}` }); }); diff --git a/test-parser.mjs b/test-parser.mjs index 5b5b119..d376546 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -570,6 +570,52 @@ ok('deleted instrument removed from output', !delInstrPatched.includes('instrume ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:')); ok('ki re-serialized and logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('ki'))); +// ── patchScore stage section cleanup ───────────────────────────────────── +section('patchScore stage section: deleted instrument removes voice entry'); +// pi → mapping form, instrument: dev/piano (present) → keep +// ki → mapping form, instrument: alpha (deleted) → remove +// alpha → string form, implicit name=alpha (deleted) → remove +const stageScore = [ + 'stage:', + ' pi:', + ' direction: 1|1', + ' distance: 0', + ' instrument: dev/piano', + ' ki:', + ' direction: 2|1', + ' distance: 1', + ' instrument: alpha', + ' alpha: 2|3 0', + '', + 'instrument dev/piano:', + ' character:', + ' A: "1:0,10;1,0"', +].join('\n'); +const stageInstrList = [ + { name: 'dev/piano', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, +]; +const { text: stagePatched } = patchScore(stageScore, stageInstrList); +ok('voice with explicit deleted instrument (mapping form) removed', !stagePatched.includes(' ki:')); +ok('voice with implicit deleted name (string form) removed', !stagePatched.includes(' alpha:')); +ok('voice with explicit surviving instrument (mapping form) kept', stagePatched.includes(' pi:')); +ok('instrument dev/piano block re-serialized', stagePatched.includes('instrument dev/piano:')); + +const stageScoreKeep = [ + 'stage:', + ' pi:', + ' direction: 1|1', + ' distance: 0', + ' instrument: dev/piano', + ' ki: 2|1 1', +].join('\n'); +const stageInstrKeep = [ + { name: 'dev/piano', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, + { name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] }, +]; +const { text: stageKeptPatched } = patchScore(stageScoreKeep, stageInstrKeep); +ok('retained voice with explicit instrument key kept', stageKeptPatched.includes(' pi:')); +ok('retained voice with implicit instrument name kept', stageKeptPatched.includes(' ki:')); + // ── Motif parsing ───────────────────────────────────────────────────────── section('Motif parsing — dynamic motif'); const DYN_MOTIF = `00 bar '001P1L1M1'