// RFC-compliant YAML serializer for Sompyler instrument blocks. // Operates on the model produced by ast-parser.js buildModel(). // ── Shape ────────────────────────────────────────────────────────────────── // 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; } // ── FM / AM modulation ───────────────────────────────────────────────────── // RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE function serializeFm(fm) { let s = String(fm.frequency ?? ''); if (fm.factor) s += fm.factor; if (fm.osc) s += `@${fm.osc}`; if (fm.shape) s += `[${serializeShape(fm.shape)}]`; s += `;${fm.mod ?? ''}:${fm.base ?? ''}`; return s; } // ── Basic properties ─────────────────────────────────────────────────────── // RFC §3.2.1.1: O, A, S, R, FM go directly in the variation MAPPING. // Returns array of YAML lines at 0 indent. function basicPropLines(bp) { if (!bp) return []; const lines = []; if (bp.oscillator) lines.push(`O: ${bp.oscillator}`); const a = serializeShape(bp.A); if (a) lines.push(`A: "${a}"`); const s = serializeShape(bp.S); if (s) lines.push(`S: "${s}"`); const r = serializeShape(bp.R); if (r) lines.push(`R: "${r}"`); for (const fm of (bp.fmModulations ?? [])) lines.push(`FM: "${serializeFm(fm)}"`); return lines; } // ── Labelled property groups ─────────────────────────────────────────────── // RFC §3.2.1.2: label name (3+ lowercase chars) is the MAPPING KEY directly. // Returns array of YAML lines at 0 indent. function labelSpecLines(ls) { const inner = basicPropLines(ls.basicProperties); if (!inner.length) return [`${ls.label}:`]; return [`${ls.label}:`, ...inner.map(l => ` ${l}`)]; } // ── Variation ───────────────────────────────────────────────────────────── // Returns YAML lines for one variation MAPPING (no leading "- "). // RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level. function variationLines(v) { const lines = []; if (v.dependsOn) lines.push(`ATTR: ${v.dependsOn}`); lines.push(...basicPropLines(v.basicProperties)); for (const ls of (v.labelSpecs ?? [])) lines.push(...labelSpecLines(ls)); if (v.spread?.length) lines.push(`SPREAD: [${v.spread.join(', ')}]`); if (v.railsbackCurve) { const rc = serializeShape(v.railsbackCurve); if (rc) lines.push(`RAILSBACK_CURVE: "${rc}"`); } const vol = serializeShape(v.volumes); if (vol) lines.push(`VOLUMES: "${vol}"`); const timbre = serializeShape(v.timbre); if (timbre) lines.push(`TIMBRE: "${timbre}"`); for (const fm of (v.fmModulations ?? [])) lines.push(`FM: "${serializeFm(fm)}"`); for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv)); return lines; } // ── Instrument character block ───────────────────────────────────────────── // 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; const syntheticRoot = hasRootProps ? { basicProperties: instr.basicProperties, labelSpecs: [], subvariations: [], spread: null, dependsOn: null, railsbackCurve: null, volumes: instr.volumes, timbre: instr.timbre, fmModulations: instr.fmModulations ?? [], } : 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. const result = []; for (const v of allVariations) { const vLines = variationLines(v); if (!vLines.length) continue; result.push(` - ${vLines[0]}`); for (const l of vLines.slice(1)) result.push(` ${l}`); } return result; } // ── Instrument ──────────────────────────────────────────────────────────── // 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'); } // ── Score patch ──────────────────────────────────────────────────────────── // Replace dirty instrument blocks in rawScoreText with RFC-serialized output. // Non-dirty instruments are left verbatim. export function patchScore(rawScoreText, instruments) { const lines = rawScoreText.split('\n'); const result = []; const instrMap = {}; for (const instr of instruments) { instrMap[instr.name] = instr; if (instr.name.includes('/')) instrMap[instr.name.split('/').pop()] = instr; } 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, ''); const instr = instrMap[rawName]; 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'); }