import { stressorToString } from './util.js'; // RFC §1.3.4.5: SHAPE = [PREFIX (":" / ";")] Node 1*(";" Node) // Node = x "," y ["*" z] ["!"] // PREFIX+colon is the duration/resolution; optional START+semicolon follows. function _serializeShape(shape) { if (!shape) return null; const nodes = shape.coords.map(c => { let s = `${c.x},${c.y}`; if (c.z !== undefined && c.z !== 1) s += `*${c.z}`; if (c.isSharp) s += '!'; return s; }).join(';'); let prefix = ''; if (shape.length != null) prefix = `${shape.length}:`; if (shape.start != null) prefix += `${shape.start};`; return prefix + nodes; } // RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE function _serializeModulation(modulation) { let s = String(modulation.frequency ?? ''); if (modulation.oscillator) s += `@${modulation.oscillator}`; if (modulation.shape) s += `[${_serializeShape(modulation.shape)}]`; s += `;${modulation.mod_share ?? ''}:${modulation.base_share ?? ''}`; if (modulation.init_phase != null) s += modulation.init_phase >= 0 ? `+${modulation.init_phase}` : String(modulation.init_phase); return s; } // RFC §3.2.1.1: O, A, S, R, FM go directly in the variation MAPPING. function _basicPropLines(bp) { if (!bp) return []; const a = _serializeShape(bp.A), s = _serializeShape(bp.S), r = _serializeShape(bp.R); return [ bp.oscillator ? `O: ${bp.oscillator}` : null, a ? `A: "${a}"` : null, s ? `S: "${s}"` : null, r ? `R: "${r}"` : null, ...(bp.fmModulations ?? []).map(fm => `FM: "${_serializeModulation(fm)}"`), ...(bp.amModulations ?? []).map(am => `AM: "${_serializeModulation(am)}"`), ].filter(Boolean); } // RFC §3.2.1.2: label name (3+ lowercase chars) is the MAPPING KEY directly. function _labelSpecLines(ls) { const inner = _basicPropLines(ls.basicProperties); if (!inner.length) return [`${ls.label}:`]; return [`${ls.label}:`, ...inner.map(l => ` ${l}`)]; } // RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level. function _variationLines(variation) { const rc = _serializeShape(variation.railsbackCurve); const vol = _serializeShape(variation.volumes); const timbre = _serializeShape(variation.timbre); return [ variation.dependsOn ? `ATTR: ${variation.dependsOn}` : null, ..._basicPropLines(variation.basicProperties), ...(variation.labelSpecs ?? []).flatMap(_labelSpecLines), variation.spread?.length ? `SPREAD: [${variation.spread.join(', ')}]` : null, rc ? `RAILSBACK_CURVE: "${rc}"` : null, vol ? `VOLUMES: "${vol}"` : null, timbre ? `TIMBRE: "${timbre}"` : null, ...(variation.fmModulations ?? []).map(fm => `FM: "${_serializeModulation(fm)}"`), ...(variation.amModulations ?? []).map(am => `AM: "${_serializeModulation(am)}"`), ...(variation.subvariations ?? []).flatMap(_variationLines), ].filter(Boolean); } // VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser // stores them on the instrument because they appear at depth 01 (root variation // is implicit when no character: wrapper exists). Promote them into a synthetic // root variation here so the export structure is RFC-correct. function _instrCharacterLines(instr) { const variations = instr.variations ?? []; const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre || (instr.fmModulations ?? []).length > 0 || (instr.amModulations ?? []).length > 0; const syntheticRoot = hasRootProps ? { basicProperties: instr.basicProperties, labelSpecs: [], subvariations: [], spread: null, dependsOn: null, railsbackCurve: null, volumes: instr.volumes, timbre: instr.timbre, fmModulations: instr.fmModulations ?? [], amModulations: instr.amModulations ?? [], } : null; const allVariations = [ ...(syntheticRoot ? [syntheticRoot] : []), ...variations, ]; if (allVariations.length <= 1) { const vLines = allVariations.length ? _variationLines(allVariations[0]) : []; return vLines.map(l => ` ${l}`); } // Multiple variations — RFC MAYBE_LIST as YAML sequence. return allVariations.flatMap(v => { const vLines = _variationLines(v); if (!vLines.length) return []; return [` - ${vLines[0]}`, ...vLines.slice(1).map(l => ` ${l}`)]; }); } // RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'" export function exportInstrument(instr) { const lines = [`instrument ${instr.name}:`]; if (instr.notChangedSince) lines.push(` NOT_CHANGED_SINCE: ${instr.notChangedSince}`); lines.push(` character:`); lines.push(..._instrCharacterLines(instr)); return lines.join('\n'); } // RFC §4.3: articles: MAPPING { LABEL: { ATTR: VALUE ... } ... } function _serializeArticleValue(v) { if (typeof v === 'boolean') return String(v); if (typeof v === 'number') return String(v); const s = String(v); return /[:#\[\]{}&*!,|>'"%@`]/.test(s) ? JSON.stringify(s) : s; } 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)}`)]; }); 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 { const instrIdx = lines.findIndex(l => /^instrument\s/.test(l)); const insertAt = instrIdx !== -1 ? instrIdx : lines.length; lines.splice(insertAt, 0, newBlock, ''); } return lines.join('\n'); } // Voice note content in bar documents is left verbatim. const META_KEYS = ['title', 'composer', 'source', 'encrypter']; function _patchMetadata(text, info) { if (!info) return text; const lines = text.split('\n'); const replaced = new Set(); const out = lines.map(line => { const key = META_KEYS.find(k => line.startsWith(k + ':') && info[k] != null && info[k] !== ''); if (key) { replaced.add(key); return `${key}: ${info[key]}`; } return line; }); const prepend = META_KEYS .filter(k => !replaced.has(k) && info[k] != null && info[k] !== '') .map(k => `${k}: ${info[k]}`); if (prepend.length) out.unshift(...prepend); return out.join('\n'); } function _patchInstrumentHeader(text, instruments) { const lines = text.split('\n'); const result = []; const instrMap = Object.fromEntries(instruments.flatMap(instr => { const entries = [[instr.name, instr]]; if (instr.name.includes('/')) entries.push([instr.name.split('/').pop(), instr]); return entries; })); let i = 0; while (i < lines.length) { const line = lines[i]; const m = line.match(/^instrument\s+(.+?)\s*:/); if (m) { const rawName = m[1].replace(/^'|'$/g, ''); const instr = instrMap[rawName]; if (instr && instr.deleted) { i++; while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; } else if (instr && instr.isDirty) { 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); i++; } } return result.join('\n'); } function _patchBarMeta(doc, bar) { const props = []; const sp = stressorToString(bar.stressor); if (sp) props.push(` stress_pattern: ${sp}`); 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'); } function _buildNewBarDoc(bar) { const lines = [`_id: ${bar.id}`, '_meta:']; const sp = stressorToString(bar.stressor); if (sp) lines.push(` stress_pattern: ${sp}`); if (bar.tempoLevels != null) lines.push(` beats_per_minute: ${bar.tempoLevels}`); return lines.join('\n'); } export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = [], flags = {}) { 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) { patchedHeader = _patchArticles(patchedHeader, articles); dirtyArticles.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}` }); }); const barMap = Object.fromEntries(bars.map(b => [b.id, b])); const newBars = bars.filter(b => b.isNew && !b.deleted); 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; } const bar = barMap[m[1]]; if (!bar) { passedThrough++; patchedBarDocs.push(doc); return; } if (bar.deleted) return; if (!bar.isDirty) { passedThrough++; patchedBarDocs.push(doc); return; } log.push({ level: 'changed', path: `bar / ${bar.id}` }); patchedBarDocs.push(_patchBarMeta(doc, bar)); }); newBars.forEach(bar => { patchedBarDocs.push(_buildNewBarDoc(bar)); 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 }; }