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

397 lines
15 KiB
JavaScript

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<VARIATION> 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.1: DATE = YYYY-MM-DD HH:MM:SS
// notChangedSince from AST log is a float epoch; score YAML must have ISO date.
function _epochToISO(val) {
if (!val) return null;
if (typeof val === 'string') return val;
return new Date(val * 1000).toISOString().slice(0, 19).replace('T', ' ');
}
function _nowISO() {
return new Date().toISOString().slice(0, 19).replace('T', ' ');
}
// RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'"
export function exportInstrument(instr) {
const lines = [`instrument ${instr.name}:`];
lines.push(` NOT_CHANGED_SINCE: ${_nowISO()}`);
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 defaults = (art.properties ?? []).filter(p => p.scope !== 'overwrites' && p.name);
const overwrites = (art.properties ?? []).filter(p => p.scope === 'overwrites' && p.name);
if (!defaults.length && !overwrites.length) return [];
const lines = [` ${art.name}:`];
defaults.forEach(p => lines.push(` ${p.name}: ${_serializeArticleValue(p.value)}`));
if (overwrites.length) {
lines.push(` -important:`);
overwrites.forEach(p => lines.push(` - ${p.name}`));
}
return lines;
});
return body.length ? ['articles:', ...body].join('\n') : null;
}
function _patchArticles(text, articles) {
const newBlock = _buildArticlesBlock(articles);
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++;
if (newBlock) lines.splice(artIdx, end - artIdx, newBlock);
else lines.splice(artIdx, end - artIdx);
} else if (newBlock) {
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) {
// not in model → deleted; skip block
i++;
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
} else if (instr.isLinked && !instr._modified) {
result.push(line);
i++;
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) {
if (/^\s+NOT_CHANGED_SINCE\s*:/.test(lines[i]))
result.push(` NOT_CHANGED_SINCE: ${_epochToISO(instr.notChangedSince)}`);
else
result.push(lines[i]);
i++;
}
} else {
i++;
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
result.push(exportInstrument(instr));
result.push('');
}
} else {
result.push(line);
i++;
}
}
return result.join('\n');
}
// 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);
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 = []) {
const log = [];
const SEP = '\n---\n';
const [header, ...barDocs] = rawScoreText.split(SEP);
let patchedHeader = _patchMetadata(header, info);
if (articles.length) {
patchedHeader = _patchArticles(patchedHeader, articles);
articles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` }));
}
patchedHeader = _patchInstrumentHeader(patchedHeader, instruments);
patchedHeader = _patchStageSection(patchedHeader, instruments);
instruments.forEach(i => {
if (!(i.isLinked && !i._modified)) 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);
if (!barDocs.length) {
newBars.forEach(b => log.push({ level: 'changed', path: `bar / ${b.id}` }));
return { text: [patchedHeader, ...newBars.map(_buildNewBarDoc)].join(SEP), log };
}
const patchedBarDocs = [];
barDocs.forEach(doc => {
const m = doc.match(/^_id:\s*(\S+)/m);
if (!m) { patchedBarDocs.push(doc); return; }
const bar = barMap[m[1]];
if (!bar) return; // not in model → deleted
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}` });
});
return { text: [patchedHeader, ...patchedBarDocs].join(SEP), log };
}