diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ff2838 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test-parser.mjs diff --git a/static/ast-parser.js b/static/ast-parser.js index a88708d..a0ee43c 100644 --- a/static/ast-parser.js +++ b/static/ast-parser.js @@ -178,7 +178,6 @@ function buildInstrument(node) { isDirty: false, variations: [], basicProperties: null, - railsbackCurve: null, volumes: null, timbre: null, fmModulations: [], @@ -193,9 +192,6 @@ function buildInstrument(node) { case 'character.basic_properties': instr.basicProperties = buildBasicProperties(child); break; - case 'RAILSBACK_CURVE.shape': - instr.railsbackCurve = buildShape(child); - break; case 'VOLUMES.shape': instr.volumes = buildShape(child); break; @@ -221,6 +217,7 @@ function buildVariation(node) { labelSpecs: [], subvariations: [], spread: null, + railsbackCurve: null, rawChildren: [], }; @@ -239,6 +236,9 @@ function buildVariation(node) { case 'variation.SPREAD': v.spread = child.positionals; break; + case 'RAILSBACK_CURVE.shape': + v.railsbackCurve = buildShape(child); + break; default: v.rawChildren.push(buildGeneric(child)); } @@ -267,7 +267,10 @@ function buildBasicProperties(node) { } else if (child.parentSlot === 'variation' && child.slot === 'O') { bp.oscillator = child.props.ref ?? child.positionals[0]; } else if (child.parentSlot === 'FM' && child.slot === 'modulation') { - bp.fmModulations.push({ ...child.props }); + const fm = { ...child.props }; + const envChild = child.children.find(c => c.slot === 'shape'); + if (envChild) fm.shape = buildShape(envChild); + bp.fmModulations.push(fm); } else { bp.rawChildren.push(buildGeneric(child)); } @@ -284,13 +287,23 @@ function buildLabelSpec(node) { rawChildren: [], }; + const directBpChildren = []; for (const child of node.children) { if (child.parentSlot === 'variation' && child.slot === 'basic_properties') { ls.basicProperties = buildBasicProperties(child); + } else if ( + (child.slot === 'shape' && (child.parentSlot === 'A' || child.parentSlot === 'S' || child.parentSlot === 'R')) || + (child.parentSlot === 'variation' && child.slot === 'O') || + (child.parentSlot === 'FM' && child.slot === 'modulation') + ) { + directBpChildren.push(child); } else { ls.rawChildren.push(buildGeneric(child)); } } + if (!ls.basicProperties && directBpChildren.length > 0) { + ls.basicProperties = buildBasicProperties({ children: directBpChildren }); + } return ls; } @@ -300,7 +313,6 @@ function buildShape(node) { type: 'shape', length: node.props.length, start: node.props.start, - z: node.props.z ?? 1, coords: node.children .filter(c => c.slot === 'coords') .map(c => ({ @@ -313,15 +325,9 @@ function buildShape(node) { } function buildBar(node) { - const id = node.positionals[0] ?? ''; - const idMatch = id.match(/^(\w?)(\d+)P(\d+)L(\d+)M(\d+)$/); const bar = { type: 'bar', - id, - movement: idMatch ? idMatch[1] : '', - part: idMatch ? parseInt(idMatch[2]) : 0, - line: idMatch ? parseInt(idMatch[3]) : 0, - measure: idMatch ? parseInt(idMatch[4]) : 0, + id: node.positionals[0] ?? '', stressor: null, tempoShape: null, tempoLevels: null, diff --git a/static/exporter.js b/static/exporter.js index 430c416..4227e16 100644 --- a/static/exporter.js +++ b/static/exporter.js @@ -1,138 +1,154 @@ -// Template-based YAML serializer — instrument blocks only (v1). -// Each object selects a template by finding the first entry in its -// selectExportTemplate() list where all required slots have values. -// Placeholders #0, #1, ... are filled with slot values. - -function fillTemplate(template, slots) { - return template.replace(/#(\d+)/g, (_, i) => slots[parseInt(i, 10)] ?? ''); -} - -function indent(text, level) { - const pad = ' '.repeat(level); - return text.split('\n').map((line, i) => { - if (i === 0) return line; - if (line.startsWith('- ')) return pad.slice(2) + line; - return pad + line; - }).join('\n'); -} +// 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 exportCoord(coord) { - let s = `x=${coord.x} y=${coord.y}`; - if (coord.z !== undefined && coord.z !== 1) s += ` z=${coord.z}`; - if (coord.isSharp) s += ` is_sharp=True`; +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; } -function exportShape(shape, slotName, level) { - if (!shape) return ''; - const coordLines = shape.coords.map(c => ` - coords: ${exportCoord(c)}`).join('\n'); - let header = `${slotName}: length=${shape.length}`; - if (shape.start !== undefined) header += ` start=${shape.start}`; - if (shape.z !== undefined && shape.z !== 1) header += ` z=${shape.z}`; - const block = coordLines ? `${header}\n${coordLines}` : header; - return indent(block, level); -} +// ── 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. -// ── BasicProperties ──────────────────────────────────────────────────────── - -function exportBasicProperties(bp, level) { - if (!bp) return ''; +function basicPropLines(bp) { + if (!bp) return []; const lines = []; - if (bp.oscillator) lines.push(` O: ref=${bp.oscillator}`); - if (bp.A) lines.push(` ${exportShape(bp.A, 'A', 1)}`); - if (bp.S) lines.push(` ${exportShape(bp.S, 'S', 1)}`); - if (bp.R) lines.push(` ${exportShape(bp.R, 'R', 1)}`); - for (const fm of (bp.fmModulations ?? [])) { - const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' '); - lines.push(` FM:\n modulation: ${parts}`); - } - if (!lines.length) return ''; - return indent('basic_properties:\n' + lines.join('\n'), level); + 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; } -// ── LabelSpec ───────────────────────────────────────────────────────────── +// ── 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 exportLabelSpec(ls, level) { - const label = ls.label ? ` '${ls.label}'` : ''; - const bp = exportBasicProperties(ls.basicProperties, 1); - const body = bp ? `label_spec:${label}\n ${bp}` : `label_spec:${label}`; - return indent(body, level); +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 "- "). -function exportVariation(v, level) { - const dep = v.dependsOn ? ` depends_on=${v.dependsOn}` : ''; - const lines = [`variation:${dep}`]; - if (v.basicProperties) lines.push(` ${exportBasicProperties(v.basicProperties, 1)}`); - for (const ls of (v.labelSpecs ?? [])) lines.push(` ${exportLabelSpec(ls, 1)}`); - for (const sv of (v.subvariations ?? [])) lines.push(` ${exportVariation(sv, 1)}`); - if (v.spread?.length) lines.push(` SPREAD: ${v.spread.join(' ')}`); - return indent(lines.join('\n'), 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}"`); } + for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv)); + return lines; +} + +// ── Instrument character block ───────────────────────────────────────────── +// VOLUMES / TIMBRE / FM appear at depth 01 (direct instrument children per RFC §3.2.1.3). +// RAILSBACK_CURVE is depth 02 (inside variation) and is emitted by variationLines(). + +function instrCharacterLines(instr) { + const extraLines = []; + const vol = serializeShape(instr.volumes); + if (vol) extraLines.push(`VOLUMES: "${vol}"`); + const timbre = serializeShape(instr.timbre); + if (timbre) extraLines.push(`TIMBRE: "${timbre}"`); + for (const fm of (instr.fmModulations ?? [])) extraLines.push(`FM: "${serializeFm(fm)}"`); + + const variations = instr.variations ?? []; + const syntheticRoot = instr.basicProperties + ? { basicProperties: instr.basicProperties, labelSpecs: [], subvariations: [], spread: null, dependsOn: null } + : null; + + const allVariations = [ + ...(syntheticRoot ? [syntheticRoot] : []), + ...variations, + ]; + + if (allVariations.length <= 1) { + // Single variation — emit as MAPPING directly under character: + const vLines = allVariations.length + ? [...variationLines(allVariations[0]), ...extraLines] + : extraLines; + return vLines.map(l => ` ${l}`); + } + + // Multiple variations — RFC MAYBE_LIST as YAML sequence. + const result = []; + for (let i = 0; i < allVariations.length; i++) { + const vLines = variationLines(allVariations[i]); + const allLines = i === 0 ? [...vLines, ...extraLines] : vLines; + if (!allLines.length) continue; + result.push(` - ${allLines[0]}`); + for (const l of allLines.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 = []; - const name = instr.name; - lines.push(`instrument: '${name}'`); - - for (const v of (instr.variations ?? [])) { - lines.push(` character:\n ${exportVariation(v, 2)}`); - } - - if (instr.basicProperties) { - lines.push(` character:\n ${exportBasicProperties(instr.basicProperties, 2)}`); - } - - if (instr.railsbackCurve) { - lines.push(` ${exportShape(instr.railsbackCurve, 'RAILSBACK_CURVE', 1)}`); - } - if (instr.volumes) { - lines.push(` ${exportShape(instr.volumes, 'VOLUMES', 1)}`); - } - if (instr.timbre) { - lines.push(` ${exportShape(instr.timbre, 'TIMBRE', 1)}`); - } - for (const fm of (instr.fmModulations ?? [])) { - const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' '); - lines.push(` FM:\n modulation: ${parts}`); - } - + 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. -// Replace instrument blocks in rawScoreText with serialized model instruments. -// Non-dirty linked instruments (NOT_CHANGED_SINCE set, not edited) are left as-is -// from rawScoreText. Embedded and dirty instruments are emitted from the model. export function patchScore(rawScoreText, instruments) { - // Split raw text into instrument blocks and other sections. - // Strategy: locate each `^instrument:` line and replace that block - // (up to next same-indent section or EOF) with the serialized model. - const lines = rawScoreText.split('\n'); const result = []; const instrMap = {}; for (const instr of instruments) { - const basename = instr.name.includes('/') ? instr.name.split('/').pop() : instr.name; - instrMap[basename] = instr; 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]; - const m = line.match(/^instrument:\s+'?([^']+)'?/); + // RFC §4.4: "instrument NAME:" — strip optional quotes around name + const m = line.match(/^instrument\s+(.+?)\s*:/); if (m) { - const rawName = m[1]; + const rawName = m[1].replace(/^'|'$/g, ''); const instr = instrMap[rawName]; if (instr && instr.isDirty) { - // consume the raw block i++; while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; result.push(exportInstrument(instr));