Files
vue3js-app-proposal-for-sdk…/static/exporter.js

341 lines
14 KiB
JavaScript

import { stressorToString } from './util.js';
// ── 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 serializeModulation(m) {
let s = String(m.frequency ?? '');
if (m.oscillator) s += `@${m.oscillator}`;
if (m.shape) s += `[${serializeShape(m.shape)}]`;
s += `;${m.mod_share ?? ''}:${m.base_share ?? ''}`;
if (m.init_phase != null) s += m.init_phase >= 0 ? `+${m.init_phase}` : String(m.init_phase);
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: "${serializeModulation(fm)}"`);
for (const am of (bp.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
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: "${serializeModulation(fm)}"`);
for (const am of (v.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
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 ||
(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<VARIATION> 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');
}
// ── Articles ───────────────────────────────────────────────────────────────
// 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 lines = ['articles:'];
for (const art of articles) {
const props = (art.properties ?? []).filter(p => p.name);
if (!props.length) continue;
lines.push(` ${art.name}:`);
for (const p of props) lines.push(` ${p.name}: ${serializeArticleValue(p.value)}`);
}
return lines.length > 1 ? lines.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');
}
// ── Score patch ────────────────────────────────────────────────────────────
// Replace dirty article, instrument, and bar _meta blocks.
// 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 => {
for (const key of META_KEYS) {
if (line.startsWith(key + ':') && info[key] != null && info[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 = {};
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];
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);
for (const a of dirtyArticles) log.push({ level: 'changed', path: `articles / ${a.name}` });
}
patchedHeader = patchInstrumentHeader(patchedHeader, instruments);
for (const i of instruments) {
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 = {};
for (const bar of bars) barMap[bar.id] = bar;
if (!barDocs.length) {
const newBarDocs = bars.filter(b => b.isNew && !b.deleted).map(b => buildNewBarDoc(b));
for (const b of bars.filter(b => b.isNew && !b.deleted)) log.push({ level: 'changed', path: `bar / ${b.id}` });
return { text: [patchedHeader, ...newBarDocs].join(SEP), log };
}
let passedThrough = 0;
const patchedBarDocs = [];
for (const doc of barDocs) {
const m = doc.match(/^_id:\s*(\S+)/m);
if (!m) { passedThrough++; patchedBarDocs.push(doc); continue; }
const bar = barMap[m[1]];
if (!bar) { passedThrough++; patchedBarDocs.push(doc); continue; }
if (bar.deleted) continue;
if (!bar.isDirty) { passedThrough++; patchedBarDocs.push(doc); continue; }
log.push({ level: 'changed', path: `bar / ${bar.id}` });
patchedBarDocs.push(patchBarMeta(doc, bar));
}
for (const bar of bars.filter(b => b.isNew && !b.deleted)) {
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 };
}