Compare commits
13 Commits
7fb584d929
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c89711be | ||
|
|
f18f07dc22 | ||
|
|
d8e6e7a02c | ||
|
|
d79599ede1 | ||
|
|
7ffa0a9d2d | ||
|
|
e2b7af77d6 | ||
|
|
fc31c40fc3 | ||
|
|
e908bd4768 | ||
|
|
a0104214b7 | ||
|
|
bf35441b77 | ||
|
|
d3a55cb663 | ||
|
|
884955195a | ||
|
|
3beaa1d2ec |
7091
fixtures/ast.log
7091
fixtures/ast.log
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,10 @@ export function parseAstLog(text) {
|
|||||||
slot = slotFull.slice(dotIdx + 1);
|
slot = slotFull.slice(dotIdx + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { positionals, props } = parseRest(rest);
|
const { positionals, props } = _parseRest(rest);
|
||||||
|
|
||||||
const node = { slot, parentSlot, depth, positionals, props, children: [] };
|
const node = { slot, parentSlot, depth, positionals, props, children: [] };
|
||||||
|
|
||||||
// Pop stack to find correct parent (parent must have depth < current)
|
|
||||||
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) {
|
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) {
|
||||||
stack.pop();
|
stack.pop();
|
||||||
}
|
}
|
||||||
@@ -46,61 +45,23 @@ export function parseAstLog(text) {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRest(rest) {
|
const _REST_TOKEN = /(\w+=)?('[^']*'|\S+)/g;
|
||||||
|
|
||||||
|
function _parseRest(rest) {
|
||||||
const positionals = [];
|
const positionals = [];
|
||||||
const props = {};
|
const props = {};
|
||||||
let i = 0;
|
|
||||||
const n = rest.length;
|
|
||||||
let inProps = false;
|
let inProps = false;
|
||||||
|
|
||||||
while (i < n) {
|
for (const [, keyEq, raw] of rest.matchAll(_REST_TOKEN)) {
|
||||||
// skip spaces
|
const val = coerce(raw.startsWith("'") ? raw.slice(1, -1) : raw);
|
||||||
while (i < n && rest[i] === ' ') i++;
|
if (keyEq !== undefined) { inProps = true; props[keyEq.slice(0, -1)] = val; }
|
||||||
if (i >= n) break;
|
else if (!inProps) { positionals.push(val); }
|
||||||
|
|
||||||
if (rest[i] === "'") {
|
|
||||||
// single-quoted value (positional string)
|
|
||||||
const j = rest.indexOf("'", i + 1);
|
|
||||||
const val = rest.slice(i + 1, j === -1 ? n : j);
|
|
||||||
i = j === -1 ? n : j + 1;
|
|
||||||
if (!inProps) positionals.push(val);
|
|
||||||
} else {
|
|
||||||
// scan to next space
|
|
||||||
let j = i;
|
|
||||||
while (j < n && rest[j] !== ' ') j++;
|
|
||||||
const tok = rest.slice(i, j);
|
|
||||||
i = j;
|
|
||||||
|
|
||||||
const eqIdx = tok.indexOf('=');
|
|
||||||
if (eqIdx !== -1) {
|
|
||||||
inProps = true;
|
|
||||||
const key = tok.slice(0, eqIdx);
|
|
||||||
let rawVal = tok.slice(eqIdx + 1);
|
|
||||||
|
|
||||||
if (rawVal.startsWith("'")) {
|
|
||||||
// value continues until next single quote
|
|
||||||
const valStart = i - (tok.length - eqIdx - 1);
|
|
||||||
// find closing quote: search from after the opening quote
|
|
||||||
const openPos = rest.indexOf("'", rest.lastIndexOf(key + '=', i) + key.length + 1);
|
|
||||||
const closePos = rest.indexOf("'", openPos + 1);
|
|
||||||
rawVal = closePos === -1 ? rest.slice(openPos + 1) : rest.slice(openPos + 1, closePos);
|
|
||||||
i = closePos === -1 ? n : closePos + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
props[key] = coerce(rawVal);
|
|
||||||
} else if (!inProps) {
|
|
||||||
positionals.push(coerce(tok));
|
|
||||||
}
|
|
||||||
// bare token after props start is ignored (shouldn't happen per spec)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { positionals, props };
|
return { positionals, props };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Second pass: build typed model ──────────────────────────────────────────
|
function _collectUnknownProps(nodeProps, knownKeys) {
|
||||||
|
|
||||||
function collectUnknownProps(nodeProps, knownKeys) {
|
|
||||||
return Object.fromEntries(Object.entries(nodeProps).filter(([k]) => !knownKeys.has(k)));
|
return Object.fromEntries(Object.entries(nodeProps).filter(([k]) => !knownKeys.has(k)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,56 +104,62 @@ export function buildModel(rawTree) {
|
|||||||
score.info = { ...node.props };
|
score.info = { ...node.props };
|
||||||
break;
|
break;
|
||||||
case 'tuning':
|
case 'tuning':
|
||||||
score.tuning = buildTuning(node);
|
score.tuning = _buildTuning(node);
|
||||||
break;
|
break;
|
||||||
case 'stage.cone':
|
case 'stage.cone':
|
||||||
score.stageCone = { type: 'stage_cone', ...node.props };
|
score.stageCone = { type: 'stage_cone', ...node.props };
|
||||||
break;
|
break;
|
||||||
case 'stage.voice':
|
case 'stage.article':
|
||||||
score.stageVoices.push({
|
score.articles.push(_buildArticleEntry(node));
|
||||||
|
break;
|
||||||
|
case 'stage.voice': {
|
||||||
|
const sv = {
|
||||||
type: 'stage_voice',
|
type: 'stage_voice',
|
||||||
name: node.positionals[0],
|
name: node.positionals[0],
|
||||||
direction: node.props.direction,
|
direction: node.props.direction,
|
||||||
distance: node.props.distance,
|
distance: node.props.distance,
|
||||||
});
|
articles: [],
|
||||||
|
};
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.parentSlot === 'voice' && child.slot === 'article')
|
||||||
|
sv.articles.push(_buildArticleEntry(child));
|
||||||
|
}
|
||||||
|
score.stageVoices.push(sv);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'instrument':
|
case 'instrument':
|
||||||
score.instruments.push(buildInstrument(node));
|
score.instruments.push(_buildInstrument(node));
|
||||||
break;
|
break;
|
||||||
case 'bar':
|
case 'bar':
|
||||||
score.bars.push(buildBar(node));
|
score.bars.push(_buildBar(node));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (node.parentSlot === 'articles') {
|
score[node.slot] = _buildGeneric(node);
|
||||||
mergeArticleEntry(score, node);
|
|
||||||
} else {
|
|
||||||
score[node.slot] = buildGeneric(node);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Articles are keyed by label. The AST emits one line per (label, subtype)
|
function _buildArticleEntry(node) {
|
||||||
// — e.g. `articles.defaults 'f'` and (future) `articles.overwrites 'f'`.
|
const entry = { type: 'article', name: node.positionals[0], properties: [] };
|
||||||
// We merge them into one entry per label whose properties carry the scope
|
for (const child of node.children) {
|
||||||
// of their originating subtype, so the UI can toggle scope per property.
|
if (child.parentSlot !== 'article') continue;
|
||||||
function mergeArticleEntry(score, node) {
|
if (child.slot === 'defaults' || child.slot === 'definite') {
|
||||||
const name = node.positionals[0];
|
const raw = child.props.constant ?? child.props.stacked ?? child.props.static;
|
||||||
let entry = score.articles.find(a => a.name === name);
|
const shapeChild = child.children.find(c => c.slot === 'shape');
|
||||||
if (!entry) {
|
const value = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null);
|
||||||
entry = { type: 'article', name, properties: [] };
|
entry.properties.push({ name: child.positionals[0], value, scope: child.slot, overwritten: child.props.overwritten ?? false });
|
||||||
score.articles.push(entry);
|
} else if (child.slot === 'overwrites') {
|
||||||
}
|
for (const propName of child.positionals) {
|
||||||
const scope = node.slot; // 'defaults' | 'overwrites' | future subtype
|
entry.properties.push({ name: propName, value: null, scope: 'overwrites' });
|
||||||
for (const [key, value] of Object.entries(node.props)) {
|
}
|
||||||
entry.properties.push({ name: key, value, scope });
|
}
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTuning(node) {
|
function _buildTuning(node) {
|
||||||
const t = { type: 'tuning', base: node.props.base, scales: {}, chords: {}, frequencyFactors: null };
|
const t = { type: 'tuning', base: node.props.base, scales: {}, chords: {}, frequencyFactors: null };
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.slot === 'scales') {
|
if (child.slot === 'scales') {
|
||||||
@@ -209,13 +176,12 @@ function buildTuning(node) {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInstrument(node) {
|
function _buildInstrument(node) {
|
||||||
const instr = {
|
const instr = {
|
||||||
type: 'instrument',
|
type: 'instrument',
|
||||||
name: node.positionals[0],
|
name: node.positionals[0],
|
||||||
notChangedSince: node.props.NOT_CHANGED_SINCE ?? null,
|
notChangedSince: node.props.NOT_CHANGED_SINCE ?? null,
|
||||||
isLinked: (node.positionals[0] ?? '').includes('/'),
|
isLinked: (node.positionals[0] ?? '').includes('/'),
|
||||||
isDirty: false,
|
|
||||||
variations: [],
|
variations: [],
|
||||||
basicProperties: null,
|
basicProperties: null,
|
||||||
volumes: null,
|
volumes: null,
|
||||||
@@ -228,16 +194,16 @@ function buildInstrument(node) {
|
|||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
switch (child.parentSlot + '.' + child.slot) {
|
switch (child.parentSlot + '.' + child.slot) {
|
||||||
case 'character.variation':
|
case 'character.variation':
|
||||||
instr.variations.push(buildVariation(child));
|
instr.variations.push(_buildVariation(child));
|
||||||
break;
|
break;
|
||||||
case 'character.basic_properties':
|
case 'character.basic_properties':
|
||||||
instr.basicProperties = buildBasicProperties(child);
|
instr.basicProperties = _buildBasicProperties(child);
|
||||||
break;
|
break;
|
||||||
case 'VOLUMES.shape':
|
case 'VOLUMES.shape':
|
||||||
instr.volumes = buildShape(child);
|
instr.volumes = _buildShape(child);
|
||||||
break;
|
break;
|
||||||
case 'TIMBRE.shape':
|
case 'TIMBRE.shape':
|
||||||
instr.timbre = buildShape(child);
|
instr.timbre = _buildShape(child);
|
||||||
break;
|
break;
|
||||||
case 'FM.modulation':
|
case 'FM.modulation':
|
||||||
instr.fmModulations.push({ ...child.props });
|
instr.fmModulations.push({ ...child.props });
|
||||||
@@ -246,14 +212,14 @@ function buildInstrument(node) {
|
|||||||
instr.amModulations.push({ ...child.props });
|
instr.amModulations.push({ ...child.props });
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
instr.unknownSlots.push(buildGeneric(child));
|
instr.unknownSlots.push(_buildGeneric(child));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return instr;
|
return instr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVariation(node) {
|
function _buildVariation(node) {
|
||||||
const v = {
|
const v = {
|
||||||
type: 'variation',
|
type: 'variation',
|
||||||
dependsOn: node.props.depends_on ?? node.props.for_value ?? null,
|
dependsOn: node.props.depends_on ?? node.props.for_value ?? null,
|
||||||
@@ -269,29 +235,29 @@ function buildVariation(node) {
|
|||||||
const key = (child.parentSlot ?? child.slot) + '.' + child.slot;
|
const key = (child.parentSlot ?? child.slot) + '.' + child.slot;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'variation.basic_properties':
|
case 'variation.basic_properties':
|
||||||
v.basicProperties = buildBasicProperties(child);
|
v.basicProperties = _buildBasicProperties(child);
|
||||||
break;
|
break;
|
||||||
case 'variation.label_spec':
|
case 'variation.label_spec':
|
||||||
v.labelSpecs.push(buildLabelSpec(child));
|
v.labelSpecs.push(_buildLabelSpec(child));
|
||||||
break;
|
break;
|
||||||
case 'variation.subvariation':
|
case 'variation.subvariation':
|
||||||
v.subvariations.push(buildVariation(child));
|
v.subvariations.push(_buildVariation(child));
|
||||||
break;
|
break;
|
||||||
case 'variation.SPREAD':
|
case 'variation.SPREAD':
|
||||||
v.spread = child.positionals;
|
v.spread = child.positionals;
|
||||||
break;
|
break;
|
||||||
case 'RAILSBACK_CURVE.shape':
|
case 'RAILSBACK_CURVE.shape':
|
||||||
v.railsbackCurve = buildShape(child);
|
v.railsbackCurve = _buildShape(child);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
v.unknownSlots.push(buildGeneric(child));
|
v.unknownSlots.push(_buildGeneric(child));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBasicProperties(node) {
|
function _buildBasicProperties(node) {
|
||||||
const bp = {
|
const bp = {
|
||||||
type: 'basic_properties',
|
type: 'basic_properties',
|
||||||
A: null, S: null, R: null,
|
A: null, S: null, R: null,
|
||||||
@@ -304,32 +270,32 @@ function buildBasicProperties(node) {
|
|||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
if (child.parentSlot === 'A' && child.slot === 'shape') {
|
if (child.parentSlot === 'A' && child.slot === 'shape') {
|
||||||
bp.A = buildShape(child);
|
bp.A = _buildShape(child);
|
||||||
} else if (child.parentSlot === 'S' && child.slot === 'shape') {
|
} else if (child.parentSlot === 'S' && child.slot === 'shape') {
|
||||||
bp.S = buildShape(child);
|
bp.S = _buildShape(child);
|
||||||
} else if (child.parentSlot === 'R' && child.slot === 'shape') {
|
} else if (child.parentSlot === 'R' && child.slot === 'shape') {
|
||||||
bp.R = buildShape(child);
|
bp.R = _buildShape(child);
|
||||||
} else if (child.parentSlot === 'variation' && child.slot === 'O') {
|
} else if (child.parentSlot === 'variation' && child.slot === 'O') {
|
||||||
bp.oscillator = child.props.ref ?? child.positionals[0];
|
bp.oscillator = child.props.ref ?? child.positionals[0];
|
||||||
} else if (child.parentSlot === 'FM' && child.slot === 'modulation') {
|
} else if (child.parentSlot === 'FM' && child.slot === 'modulation') {
|
||||||
const fm = { ...child.props };
|
const fm = { ...child.props };
|
||||||
const envChild = child.children.find(c => c.slot === 'shape');
|
const envChild = child.children.find(c => c.slot === 'shape');
|
||||||
if (envChild) fm.shape = buildShape(envChild);
|
if (envChild) fm.shape = _buildShape(envChild);
|
||||||
bp.fmModulations.push(fm);
|
bp.fmModulations.push(fm);
|
||||||
} else if (child.parentSlot === 'AM' && child.slot === 'modulation') {
|
} else if (child.parentSlot === 'AM' && child.slot === 'modulation') {
|
||||||
const am = { ...child.props };
|
const am = { ...child.props };
|
||||||
const envChild = child.children.find(c => c.slot === 'shape');
|
const envChild = child.children.find(c => c.slot === 'shape');
|
||||||
if (envChild) am.shape = buildShape(envChild);
|
if (envChild) am.shape = _buildShape(envChild);
|
||||||
bp.amModulations.push(am);
|
bp.amModulations.push(am);
|
||||||
} else {
|
} else {
|
||||||
bp.unknownSlots.push(buildGeneric(child));
|
bp.unknownSlots.push(_buildGeneric(child));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bp;
|
return bp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLabelSpec(node) {
|
function _buildLabelSpec(node) {
|
||||||
const ls = {
|
const ls = {
|
||||||
type: 'label_spec',
|
type: 'label_spec',
|
||||||
label: node.positionals[0],
|
label: node.positionals[0],
|
||||||
@@ -340,7 +306,7 @@ function buildLabelSpec(node) {
|
|||||||
const directBpChildren = [];
|
const directBpChildren = [];
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.parentSlot === 'variation' && child.slot === 'basic_properties') {
|
if (child.parentSlot === 'variation' && child.slot === 'basic_properties') {
|
||||||
ls.basicProperties = buildBasicProperties(child);
|
ls.basicProperties = _buildBasicProperties(child);
|
||||||
} else if (
|
} else if (
|
||||||
(child.slot === 'shape' && (child.parentSlot === 'A' || child.parentSlot === 'S' || child.parentSlot === 'R')) ||
|
(child.slot === 'shape' && (child.parentSlot === 'A' || child.parentSlot === 'S' || child.parentSlot === 'R')) ||
|
||||||
(child.parentSlot === 'variation' && child.slot === 'O') ||
|
(child.parentSlot === 'variation' && child.slot === 'O') ||
|
||||||
@@ -348,17 +314,17 @@ function buildLabelSpec(node) {
|
|||||||
) {
|
) {
|
||||||
directBpChildren.push(child);
|
directBpChildren.push(child);
|
||||||
} else {
|
} else {
|
||||||
ls.unknownSlots.push(buildGeneric(child));
|
ls.unknownSlots.push(_buildGeneric(child));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!ls.basicProperties && directBpChildren.length > 0) {
|
if (!ls.basicProperties && directBpChildren.length > 0) {
|
||||||
ls.basicProperties = buildBasicProperties({ children: directBpChildren });
|
ls.basicProperties = _buildBasicProperties({ children: directBpChildren });
|
||||||
}
|
}
|
||||||
|
|
||||||
return ls;
|
return ls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildShape(node) {
|
function _buildShape(node) {
|
||||||
return {
|
return {
|
||||||
type: 'shape',
|
type: 'shape',
|
||||||
length: node.props.length,
|
length: node.props.length,
|
||||||
@@ -374,11 +340,10 @@ function buildShape(node) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBar(node) {
|
function _buildBar(node) {
|
||||||
const bar = {
|
const bar = {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
id: node.positionals[0] ?? '',
|
id: node.positionals[0] ?? '',
|
||||||
isDirty: false,
|
|
||||||
stressor: null,
|
stressor: null,
|
||||||
tempoShape: null,
|
tempoShape: null,
|
||||||
tempoLevels: null,
|
tempoLevels: null,
|
||||||
@@ -393,35 +358,35 @@ function buildBar(node) {
|
|||||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
switch (fqSlot) {
|
switch (fqSlot) {
|
||||||
case 'stress_pattern.stressor':
|
case 'stress_pattern.stressor':
|
||||||
bar.stressor = buildStressor(child);
|
bar.stressor = _buildStressor(child);
|
||||||
break;
|
break;
|
||||||
case 'tempo.shape':
|
case 'tempo.shape':
|
||||||
bar.tempoShape = buildShape(child);
|
bar.tempoShape = _buildShape(child);
|
||||||
break;
|
break;
|
||||||
case 'tempo.levels':
|
case 'tempo.levels':
|
||||||
bar.tempoLevels = child.positionals[0];
|
bar.tempoLevels = child.positionals[0];
|
||||||
break;
|
break;
|
||||||
case 'lower_stress_bound.shape':
|
case 'lower_stress_bound.shape':
|
||||||
bar.lowerStressBound = buildShape(child);
|
bar.lowerStressBound = _buildShape(child);
|
||||||
break;
|
break;
|
||||||
case 'upper_stress_bound.shape':
|
case 'upper_stress_bound.shape':
|
||||||
bar.upperStressBound = buildShape(child);
|
bar.upperStressBound = _buildShape(child);
|
||||||
break;
|
break;
|
||||||
case 'bar.tuning':
|
case 'bar.tuning':
|
||||||
bar.tunings.push({ ...child.props });
|
bar.tunings.push({ ...child.props });
|
||||||
break;
|
break;
|
||||||
case 'bar.voice':
|
case 'bar.voice':
|
||||||
bar.voices[child.positionals[0]] = buildVoice(child);
|
bar.voices[child.positionals[0]] = _buildVoice(child);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
bar.unknownSlots.push(buildGeneric(child));
|
bar.unknownSlots.push(_buildGeneric(child));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStressor(node) {
|
function _buildStressor(node) {
|
||||||
const levels = [];
|
const levels = [];
|
||||||
let currentGroup = [];
|
let currentGroup = [];
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
@@ -436,7 +401,7 @@ function buildStressor(node) {
|
|||||||
return { type: 'stressor', groups: levels };
|
return { type: 'stressor', groups: levels };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVoice(node) {
|
function _buildVoice(node) {
|
||||||
const voice = {
|
const voice = {
|
||||||
type: 'voice',
|
type: 'voice',
|
||||||
name: node.positionals[0],
|
name: node.positionals[0],
|
||||||
@@ -449,13 +414,13 @@ function buildVoice(node) {
|
|||||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||||
switch (fqSlot) {
|
switch (fqSlot) {
|
||||||
case 'voice.offset':
|
case 'voice.offset':
|
||||||
voice.offsets.push(buildOffset(child));
|
voice.offsets.push(_buildOffset(child));
|
||||||
break;
|
break;
|
||||||
case 'voice.article':
|
case 'voice.article':
|
||||||
voice.articles.push(child.positionals[0]);
|
voice.articles.push(_buildArticleEntry(child));
|
||||||
break;
|
break;
|
||||||
case 'voice.motif':
|
case 'voice.motif':
|
||||||
voice.motifs.push(buildMotif(child));
|
voice.motifs.push(_buildMotif(child));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// ignore
|
// ignore
|
||||||
@@ -465,27 +430,35 @@ function buildVoice(node) {
|
|||||||
return voice;
|
return voice;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOffset(node) {
|
function _buildOffset(node) {
|
||||||
const offset = {
|
const offset = {
|
||||||
type: 'offset',
|
type: 'offset',
|
||||||
tick: node.props.tick,
|
tick: node.props.tick,
|
||||||
stemNotes: [],
|
stemNotes: [],
|
||||||
motifs: [],
|
motifRefs: [],
|
||||||
unknownProps: collectUnknownProps(node.props, new Set(['tick'])),
|
unknownProps: _collectUnknownProps(node.props, new Set(['tick'])),
|
||||||
};
|
};
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.parentSlot === 'line' && child.slot === 'stem_note')
|
if (child.parentSlot === 'line' && child.slot === 'stem_note')
|
||||||
offset.stemNotes.push(buildStemNote(child));
|
offset.stemNotes.push(_buildStemNote(child));
|
||||||
else if (child.parentSlot === 'line' && child.slot === 'motif')
|
else if (child.parentSlot === 'line' && child.slot === 'motif')
|
||||||
offset.motifs.push({ label: child.positionals[0], chord: child.props.chord ?? null });
|
offset.motifRefs.push({ label: child.positionals[0], chord: child.props.chord ?? null });
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStemNote(node) {
|
function _buildStemNote(node) {
|
||||||
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent']);
|
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent', 'weight', 'articulatory']);
|
||||||
const chainNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'chain');
|
const chainNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'chain');
|
||||||
const writeToNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'write_to');
|
const writeToNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'write_to');
|
||||||
|
const definiteProps = {};
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.parentSlot === 'article' && child.slot === 'definite') {
|
||||||
|
const raw = child.props.constant ?? child.props.stacked ?? child.props.static;
|
||||||
|
const shapeChild = child.children.find(c => c.slot === 'shape');
|
||||||
|
definiteProps[child.positionals[0]] = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'stem_note',
|
type: 'stem_note',
|
||||||
pitch: node.props.pitch,
|
pitch: node.props.pitch,
|
||||||
@@ -493,18 +466,18 @@ function buildStemNote(node) {
|
|||||||
adjacent: node.props.adjacent ?? null,
|
adjacent: node.props.adjacent ?? null,
|
||||||
adjStress: node.props.adj_stress ?? null,
|
adjStress: node.props.adj_stress ?? null,
|
||||||
length: null,
|
length: null,
|
||||||
weight: null,
|
weight: node.props.weight ?? null,
|
||||||
chainText: chainNode?.props._tmp_string ?? '',
|
chainText: chainNode?.props._tmp_string ?? '',
|
||||||
writeToName: writeToNode?.positionals[0] ?? null,
|
writeToName: writeToNode?.positionals[0] ?? null,
|
||||||
clauses: (chainNode?.children ?? [])
|
clauses: (chainNode?.children ?? [])
|
||||||
.filter(c => c.parentSlot === 'chain' && c.slot === 'clause')
|
.filter(c => c.parentSlot === 'chain' && c.slot === 'clause')
|
||||||
.map(buildClause),
|
.map(_buildClause),
|
||||||
isDirty: false,
|
...definiteProps,
|
||||||
unknownProps: collectUnknownProps(node.props, KNOWN),
|
unknownProps: _collectUnknownProps(node.props, KNOWN),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClause(node) {
|
function _buildClause(node) {
|
||||||
return {
|
return {
|
||||||
type: 'clause',
|
type: 'clause',
|
||||||
index: node.positionals[0] ?? 0,
|
index: node.positionals[0] ?? 0,
|
||||||
@@ -518,28 +491,28 @@ function buildClause(node) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMotif(node) {
|
function _buildMotif(node) {
|
||||||
const m = {
|
const m = {
|
||||||
type: 'motif',
|
type: 'motif',
|
||||||
label: node.props.label,
|
label: node.props.label,
|
||||||
stemNotes: [],
|
stemNotes: [],
|
||||||
unknownProps: collectUnknownProps(node.props, new Set(['label'])),
|
unknownProps: _collectUnknownProps(node.props, new Set(['label'])),
|
||||||
};
|
};
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
if (child.parentSlot === 'line' && child.slot === 'stem_note')
|
if (child.parentSlot === 'line' && child.slot === 'stem_note')
|
||||||
m.stemNotes.push(buildStemNote(child));
|
m.stemNotes.push(_buildStemNote(child));
|
||||||
}
|
}
|
||||||
m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch));
|
m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGeneric(node) {
|
function _buildGeneric(node) {
|
||||||
return {
|
return {
|
||||||
type: node.slot,
|
type: node.slot,
|
||||||
parentSlot: node.parentSlot,
|
parentSlot: node.parentSlot,
|
||||||
depth: node.depth,
|
depth: node.depth,
|
||||||
positionals: node.positionals,
|
positionals: node.positionals,
|
||||||
props: node.props,
|
props: node.props,
|
||||||
children: node.children.map(buildGeneric),
|
children: node.children.map(_buildGeneric),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
// Extended property view rendered as a definition list.
|
|
||||||
// `fields`: array of { key, value, editable?, type? }
|
|
||||||
// `onChange`: called with { key, value } when field changes.
|
|
||||||
export const ObjectExtended = {
|
export const ObjectExtended = {
|
||||||
props: ['fields', 'onChange'],
|
props: ['fields', 'onChange'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
// One-line summary row with a drill-down chevron.
|
|
||||||
export const ObjectShort = {
|
export const ObjectShort = {
|
||||||
props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly', 'deletable'],
|
props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly', 'deletable'],
|
||||||
emits: ['focus', 'drillDown', 'delete'],
|
emits: ['focus', 'drillDown', 'delete'],
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const PaneCP = {
|
|||||||
try {
|
try {
|
||||||
const text = await fetchAstLog();
|
const text = await fetchAstLog();
|
||||||
props.store.scoreModel = buildModel(parseAstLog(text));
|
props.store.scoreModel = buildModel(parseAstLog(text));
|
||||||
props.store.exportLog = [];
|
props.store.resetEditState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
importError.value = e.message;
|
importError.value = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,9 +49,7 @@ export const PaneCP = {
|
|||||||
const model = props.store.scoreModel;
|
const model = props.store.scoreModel;
|
||||||
const { text: patched, log } = patchScore(
|
const { text: patched, log } = patchScore(
|
||||||
raw, model.instruments, model.bars, model.info, model.articles ?? [],
|
raw, model.instruments, model.bars, model.info, model.articles ?? [],
|
||||||
{ articlesModified: !!model.articlesModified },
|
|
||||||
);
|
);
|
||||||
model.articlesModified = false;
|
|
||||||
props.store.exportLog = log;
|
props.store.exportLog = log;
|
||||||
await putScoreText(patched);
|
await putScoreText(patched);
|
||||||
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
||||||
@@ -118,9 +116,11 @@ export const PaneCP = {
|
|||||||
})
|
})
|
||||||
) : null,
|
) : null,
|
||||||
|
|
||||||
// Import / export errors
|
// Import / export / synthesis errors
|
||||||
importError.value ? h('div', { class: 'se-error' }, importError.value) : null,
|
importError.value ? h('div', { class: 'se-error' }, importError.value) : null,
|
||||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
||||||
|
store.synthesisStatus?.frozen && store.synthesisStatus.errors
|
||||||
|
? h('div', { class: 'se-error' }, store.synthesisStatus.errors) : null,
|
||||||
|
|
||||||
// Export log (shown after export, cleared on next import)
|
// Export log (shown after export, cleared on next import)
|
||||||
store.exportLog?.length
|
store.exportLog?.length
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ import { coerce, stressorToString } from '../util.js';
|
|||||||
|
|
||||||
const H4 = { style: 'margin:0 0 0.5rem' };
|
const H4 = { style: 'margin:0 0 0.5rem' };
|
||||||
|
|
||||||
function unknownPropFields(node) {
|
function _unknownPropFields(node) {
|
||||||
return Object.entries(node.unknownProps ?? {})
|
return Object.entries(node.unknownProps ?? {})
|
||||||
.map(([key, value]) => ({ key, value: String(value), editable: false }));
|
.map(([key, value]) => ({ key, value: String(value), editable: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStressor(str) {
|
function _parseStressor(str) {
|
||||||
const groups = str.split(';').map(seg =>
|
const groups = str.split(';').map(seg =>
|
||||||
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
|
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
|
||||||
).filter(g => g.length > 0);
|
).filter(g => g.length > 0);
|
||||||
return groups.length ? { type: 'stressor', groups } : null;
|
return groups.length ? { type: 'stressor', groups } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreInfoFields(info) {
|
function _scoreInfoFields(info) {
|
||||||
return [
|
return [
|
||||||
{ key: 'title', value: info?.title ?? '', editable: true },
|
{ key: 'title', value: info?.title ?? '', editable: true },
|
||||||
{ key: 'composer', value: info?.composer ?? '', editable: true },
|
{ key: 'composer', value: info?.composer ?? '', editable: true },
|
||||||
@@ -28,7 +28,7 @@ function scoreInfoFields(info) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function instrFields(instr) {
|
function _instrFields(instr) {
|
||||||
return [
|
return [
|
||||||
{ key: 'name', value: instr.name, editable: false },
|
{ key: 'name', value: instr.name, editable: false },
|
||||||
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
|
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
|
||||||
@@ -36,11 +36,11 @@ function instrFields(instr) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function variationFields(v) {
|
function _variationFields(v) {
|
||||||
return [{ key: 'depends_on', value: v.dependsOn ?? '—', editable: true }];
|
return [{ key: 'depends_on', value: v.dependsOn ?? '—', editable: true }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function shapeSection(label, shape, onChange) {
|
function _shapeSection(label, shape, onChange) {
|
||||||
if (!shape) return null;
|
if (!shape) return null;
|
||||||
return h('div', { style: 'margin-top:0.5rem' }, [
|
return h('div', { style: 'margin-top:0.5rem' }, [
|
||||||
h('strong', null, label),
|
h('strong', null, label),
|
||||||
@@ -58,15 +58,13 @@ export const PaneFO = {
|
|||||||
return fp.length ? fp[fp.length - 1] : null;
|
return fp.length ? fp[fp.length - 1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercepts the first edit to a linked instrument:
|
// Guard: first edit to a linked instrument triggers embed-or-discard before committing.
|
||||||
// shows embed-or-discard modal before committing. `info.undo`
|
|
||||||
// (forwarded from ShapeEditor/EnvelopeEditor) reverts the mutation on discard.
|
|
||||||
function makeChangeHandler(instr) {
|
function makeChangeHandler(instr) {
|
||||||
return (info) => {
|
return (info) => {
|
||||||
if (instr.isLinked && !instr.isDirty) {
|
if (instr.isLinked && !instr._modified) {
|
||||||
pendingEdit.value = { instr, undo: info?.undo };
|
pendingEdit.value = { instr, undo: info?.undo };
|
||||||
} else {
|
} else {
|
||||||
instr.isDirty = true;
|
instr._modified = true;
|
||||||
props.store.markDirty();
|
props.store.markDirty();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,7 +73,7 @@ export const PaneFO = {
|
|||||||
function embedInstrument(instr) {
|
function embedInstrument(instr) {
|
||||||
instr.name = instr.name.split('/').pop();
|
instr.name = instr.name.split('/').pop();
|
||||||
instr.isLinked = false;
|
instr.isLinked = false;
|
||||||
instr.isDirty = true;
|
instr._modified = true;
|
||||||
pendingEdit.value = null;
|
pendingEdit.value = null;
|
||||||
props.store.markDirty();
|
props.store.markDirty();
|
||||||
}
|
}
|
||||||
@@ -101,7 +99,7 @@ export const PaneFO = {
|
|||||||
return h('div', { class: 'se-fo-pane' }, [
|
return h('div', { class: 'se-fo-pane' }, [
|
||||||
h('h4', H4, 'Score'),
|
h('h4', H4, 'Score'),
|
||||||
h(ObjectExtended, {
|
h(ObjectExtended, {
|
||||||
fields: scoreInfoFields(model.info),
|
fields: _scoreInfoFields(model.info),
|
||||||
onChange: ({ key, value }) => {
|
onChange: ({ key, value }) => {
|
||||||
if (!model.info) model.info = {};
|
if (!model.info) model.info = {};
|
||||||
model.info[key] = value;
|
model.info[key] = value;
|
||||||
@@ -114,7 +112,7 @@ export const PaneFO = {
|
|||||||
if (node.type === 'instrument') {
|
if (node.type === 'instrument') {
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, `Instrument: ${node.name}`),
|
h('h4', H4, `Instrument: ${node.name}`),
|
||||||
h(ObjectExtended, { fields: instrFields(node), onChange: null }),
|
h(ObjectExtended, { fields: _instrFields(node), onChange: null }),
|
||||||
);
|
);
|
||||||
} else if (node.type === 'variation') {
|
} else if (node.type === 'variation') {
|
||||||
const instr = props.store.scoreModel.instruments.find(
|
const instr = props.store.scoreModel.instruments.find(
|
||||||
@@ -125,7 +123,7 @@ export const PaneFO = {
|
|||||||
|
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, 'Variation'),
|
h('h4', H4, 'Variation'),
|
||||||
h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
|
h(ObjectExtended, { fields: _variationFields(node), onChange: ({ key, value }) => {
|
||||||
if (key === 'depends_on') {
|
if (key === 'depends_on') {
|
||||||
const old = node.dependsOn;
|
const old = node.dependsOn;
|
||||||
node.dependsOn = value;
|
node.dependsOn = value;
|
||||||
@@ -154,7 +152,7 @@ export const PaneFO = {
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} else if (node.type === 'article') {
|
} else if (node.type === 'article') {
|
||||||
const markDirty = () => { node.isDirty = true; props.store.markDirty(); };
|
const markDirty = () => { props.store.markDirty(); };
|
||||||
const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0';
|
const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0';
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, `Article: ${node.name}`),
|
h('h4', H4, `Article: ${node.name}`),
|
||||||
@@ -211,32 +209,32 @@ export const PaneFO = {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} else if (node.type === 'bar') {
|
} else if (node.type === 'bar') {
|
||||||
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
|
const markBarDirty = () => { props.store.markDirty(); };
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, `Bar: ${node.id}`),
|
h('h4', H4, `Bar: ${node.id}`),
|
||||||
h(ObjectExtended, {
|
h(ObjectExtended, {
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'id', value: node.id, editable: node.isNew ?? false },
|
{ key: 'id', value: node.id, editable: node._isNew ?? false },
|
||||||
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
|
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
|
||||||
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
|
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
|
||||||
],
|
],
|
||||||
onChange: ({ key, value }) => {
|
onChange: ({ key, value }) => {
|
||||||
if (key === 'id') { node.id = value; markBarDirty(); return; }
|
if (key === 'id') { node.id = value; markBarDirty(); return; }
|
||||||
if (key === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value;
|
if (key === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value;
|
||||||
if (key === 'stress_pattern') node.stressor = parseStressor(value);
|
if (key === 'stress_pattern') node.stressor = _parseStressor(value);
|
||||||
markBarDirty();
|
markBarDirty();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
shapeSection('Upper stress bound', node.upperStressBound, markBarDirty),
|
_shapeSection('Upper stress bound', node.upperStressBound, markBarDirty),
|
||||||
shapeSection('Lower stress bound', node.lowerStressBound, markBarDirty),
|
_shapeSection('Lower stress bound', node.lowerStressBound, markBarDirty),
|
||||||
shapeSection('Tempo shape', node.tempoShape, markBarDirty),
|
_shapeSection('Tempo shape', node.tempoShape, markBarDirty),
|
||||||
);
|
);
|
||||||
} else if (node.type === 'voice') {
|
} else if (node.type === 'voice') {
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', H4, `Voice: ${node.name}`),
|
h('h4', H4, `Voice: ${node.name}`),
|
||||||
h(ObjectExtended, {
|
h(ObjectExtended, {
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
|
{ key: 'articles', value: node.articles.map(a => a.name).join(', ') || '—', editable: false },
|
||||||
{ key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false },
|
{ key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false },
|
||||||
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
||||||
],
|
],
|
||||||
@@ -251,10 +249,10 @@ export const PaneFO = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ key: 'tick', value: node.tick, editable: false },
|
{ key: 'tick', value: node.tick, editable: false },
|
||||||
{ key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false },
|
{ key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false },
|
||||||
node.motifs?.length
|
node.motifRefs?.length
|
||||||
? { key: 'motifs', value: node.motifs.map(m => m.chord ? `${m.label}(${m.chord})` : m.label).join(', '), editable: false }
|
? { key: 'motifRefs', value: node.motifRefs.map(m => m.chord ? `${m.label}(${m.chord})` : m.label).join(', '), editable: false }
|
||||||
: null,
|
: null,
|
||||||
...unknownPropFields(node),
|
..._unknownPropFields(node),
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
onChange: null,
|
onChange: null,
|
||||||
}),
|
}),
|
||||||
@@ -269,16 +267,13 @@ export const PaneFO = {
|
|||||||
{ key: 'label', value: node.label, editable: false },
|
{ key: 'label', value: node.label, editable: false },
|
||||||
{ key: 'static', value: node.isStatic, editable: false, type: 'boolean' },
|
{ key: 'static', value: node.isStatic, editable: false, type: 'boolean' },
|
||||||
{ key: 'stem notes', value: node.stemNotes.map(pitchLabel).join(', ') || '—', editable: false },
|
{ key: 'stem notes', value: node.stemNotes.map(pitchLabel).join(', ') || '—', editable: false },
|
||||||
...unknownPropFields(node),
|
..._unknownPropFields(node),
|
||||||
],
|
],
|
||||||
onChange: null,
|
onChange: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if (node.type === 'stem_note') {
|
} else if (node.type === 'stem_note') {
|
||||||
const bar = props.store.focusPath.find(n => n.type === 'bar') ?? null;
|
|
||||||
const markDirty = () => {
|
const markDirty = () => {
|
||||||
node.isDirty = true;
|
|
||||||
if (bar) bar.isDirty = true;
|
|
||||||
props.store.markDirty();
|
props.store.markDirty();
|
||||||
};
|
};
|
||||||
children.push(
|
children.push(
|
||||||
@@ -291,7 +286,7 @@ export const PaneFO = {
|
|||||||
{ key: 'adj_stress', value: node.adjStress != null ? String(node.adjStress) : '', editable: true, type: 'number' },
|
{ key: 'adj_stress', value: node.adjStress != null ? String(node.adjStress) : '', editable: true, type: 'number' },
|
||||||
{ key: 'chain', value: node.chainText, editable: true },
|
{ key: 'chain', value: node.chainText, editable: true },
|
||||||
{ key: 'clauses', value: String(node.clauses.length), editable: false },
|
{ key: 'clauses', value: String(node.clauses.length), editable: false },
|
||||||
...unknownPropFields(node),
|
..._unknownPropFields(node),
|
||||||
],
|
],
|
||||||
onChange: ({ key, value }) => {
|
onChange: ({ key, value }) => {
|
||||||
if (key === 'pitch') node.pitch = value;
|
if (key === 'pitch') node.pitch = value;
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import { h } from 'vue';
|
|||||||
import { ObjectShort } from './ObjectShort.js';
|
import { ObjectShort } from './ObjectShort.js';
|
||||||
import { getKindGroups } from '../subobject-kinds.js';
|
import { getKindGroups } from '../subobject-kinds.js';
|
||||||
|
|
||||||
// Strip the final maximal run of same-class characters (all-digits or all-non-digits)
|
function _barGroupKey(id) {
|
||||||
// from the ID tail. Everything before that run is the group key.
|
|
||||||
function barGroupKey(id) {
|
|
||||||
const m = id.match(/(\d+|\D+)$/);
|
const m = id.match(/(\d+|\D+)$/);
|
||||||
return m ? id.slice(0, m.index) : id;
|
return m ? id.slice(0, m.index) : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment the trailing numeric run of an ID, preserving zero-padding width.
|
function _incrementId(id) {
|
||||||
function incrementId(id) {
|
|
||||||
const m = id.match(/(\d+)$/);
|
const m = id.match(/(\d+)$/);
|
||||||
if (!m) return id + '1';
|
if (!m) return id + '1';
|
||||||
const num = parseInt(m[1], 10) + 1;
|
const num = parseInt(m[1], 10) + 1;
|
||||||
@@ -26,38 +23,39 @@ export const PaneSubObjects = {
|
|||||||
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _spliceNode(arr, node) {
|
||||||
|
const idx = arr.indexOf(node);
|
||||||
|
if (idx !== -1) { arr.splice(idx, 1); props.store.markDirty(); }
|
||||||
|
}
|
||||||
|
|
||||||
function deleteItem(kind, node) {
|
function deleteItem(kind, node) {
|
||||||
const store = props.store;
|
const store = props.store;
|
||||||
const model = store.scoreModel;
|
const model = store.scoreModel;
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
|
|
||||||
if (kind === 'instrument') {
|
if (kind === 'instrument') {
|
||||||
const idx = model.instruments.indexOf(node);
|
_spliceNode(model.instruments, node);
|
||||||
if (idx !== -1) { model.instruments.splice(idx, 1); store.markDirty(); }
|
|
||||||
} else if (kind === 'articles') {
|
} else if (kind === 'articles') {
|
||||||
const idx = (model.articles ?? []).indexOf(node);
|
_spliceNode(model.articles, node);
|
||||||
if (idx !== -1) { model.articles.splice(idx, 1); model.articlesModified = true; store.markDirty(); }
|
|
||||||
} else if (kind === 'bar') {
|
} else if (kind === 'bar') {
|
||||||
node.deleted = true;
|
_spliceNode(model.bars, node);
|
||||||
node.isDirty = true;
|
|
||||||
store.markDirty();
|
|
||||||
} else if (kind === 'variation') {
|
} else if (kind === 'variation') {
|
||||||
for (const instr of model.instruments) {
|
for (const instr of model.instruments) {
|
||||||
const idx = instr.variations.indexOf(node);
|
const idx = instr.variations.indexOf(node);
|
||||||
if (idx !== -1) { instr.variations.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; }
|
if (idx !== -1) { instr.variations.splice(idx, 1); instr._modified = true; store.markDirty(); return; }
|
||||||
for (const v of instr.variations) {
|
for (const v of instr.variations) {
|
||||||
const sidx = v.subvariations.indexOf(node);
|
const sidx = v.subvariations.indexOf(node);
|
||||||
if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr.isDirty = true; store.markDirty(); return; }
|
if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr._modified = true; store.markDirty(); return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (kind === 'label_spec') {
|
} else if (kind === 'label_spec') {
|
||||||
for (const instr of model.instruments) {
|
for (const instr of model.instruments) {
|
||||||
for (const v of instr.variations) {
|
for (const v of instr.variations) {
|
||||||
const idx = v.labelSpecs.indexOf(node);
|
const idx = v.labelSpecs.indexOf(node);
|
||||||
if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr.isDirty = true; store.markDirty(); return; }
|
if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr._modified = true; store.markDirty(); return; }
|
||||||
for (const sv of v.subvariations) {
|
for (const sv of v.subvariations) {
|
||||||
const idx2 = sv.labelSpecs.indexOf(node);
|
const idx2 = sv.labelSpecs.indexOf(node);
|
||||||
if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr.isDirty = true; store.markDirty(); return; }
|
if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr._modified = true; store.markDirty(); return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,13 +68,13 @@ export const PaneSubObjects = {
|
|||||||
if (!model) return;
|
if (!model) return;
|
||||||
let newId;
|
let newId;
|
||||||
if (afterId) {
|
if (afterId) {
|
||||||
newId = incrementId(afterId);
|
newId = _incrementId(afterId);
|
||||||
} else {
|
} else {
|
||||||
const liveBars = (model.bars ?? []).filter(b => !b.deleted);
|
const liveBars = model.bars ?? [];
|
||||||
newId = liveBars.length ? incrementId(liveBars[liveBars.length - 1].id) : 'bar001';
|
newId = liveBars.length ? _incrementId(liveBars[liveBars.length - 1].id) : 'bar001';
|
||||||
}
|
}
|
||||||
const newBar = {
|
const newBar = {
|
||||||
type: 'bar', id: newId, isDirty: true, isNew: true,
|
type: 'bar', id: newId, _isNew: true,
|
||||||
stressor: null, tempoLevels: null,
|
stressor: null, tempoLevels: null,
|
||||||
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
||||||
voices: {},
|
voices: {},
|
||||||
@@ -98,12 +96,10 @@ export const PaneSubObjects = {
|
|||||||
if (!items.length && props.kind !== 'bar') return h('div', null, h('em', null, 'No sub-objects'));
|
if (!items.length && props.kind !== 'bar') return h('div', null, h('em', null, 'No sub-objects'));
|
||||||
|
|
||||||
if (props.kind === 'bar') {
|
if (props.kind === 'bar') {
|
||||||
const visibleItems = items.filter(item => !item.node.deleted);
|
|
||||||
|
|
||||||
const barGroups = [];
|
const barGroups = [];
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
for (const item of visibleItems) {
|
for (const item of items) {
|
||||||
const key = barGroupKey(item.label);
|
const key = _barGroupKey(item.label);
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
const g = { key, items: [] };
|
const g = { key, items: [] };
|
||||||
barGroups.push(g);
|
barGroups.push(g);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { stressorToString } from './util.js';
|
import { stressorToString } from './util.js';
|
||||||
|
|
||||||
// ── Shape ──────────────────────────────────────────────────────────────────
|
|
||||||
// RFC §1.3.4.5: SHAPE = [PREFIX (":" / ";")] Node 1*(";" Node)
|
// RFC §1.3.4.5: SHAPE = [PREFIX (":" / ";")] Node 1*(";" Node)
|
||||||
// Node = x "," y ["*" z] ["!"]
|
// Node = x "," y ["*" z] ["!"]
|
||||||
// PREFIX+colon is the duration/resolution; optional START+semicolon follows.
|
// PREFIX+colon is the duration/resolution; optional START+semicolon follows.
|
||||||
|
|
||||||
function serializeShape(shape) {
|
function _serializeShape(shape) {
|
||||||
if (!shape) return null;
|
if (!shape) return null;
|
||||||
const nodes = shape.coords.map(c => {
|
const nodes = shape.coords.map(c => {
|
||||||
let s = `${c.x},${c.y}`;
|
let s = `${c.x},${c.y}`;
|
||||||
@@ -19,75 +18,66 @@ function serializeShape(shape) {
|
|||||||
return prefix + nodes;
|
return prefix + nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FM / AM modulation ─────────────────────────────────────────────────────
|
|
||||||
// RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
|
// RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
|
||||||
|
|
||||||
function serializeModulation(m) {
|
function _serializeModulation(modulation) {
|
||||||
let s = String(m.frequency ?? '');
|
let s = String(modulation.frequency ?? '');
|
||||||
if (m.oscillator) s += `@${m.oscillator}`;
|
if (modulation.oscillator) s += `@${modulation.oscillator}`;
|
||||||
if (m.shape) s += `[${serializeShape(m.shape)}]`;
|
if (modulation.shape) s += `[${_serializeShape(modulation.shape)}]`;
|
||||||
s += `;${m.mod_share ?? ''}:${m.base_share ?? ''}`;
|
s += `;${modulation.mod_share ?? ''}:${modulation.base_share ?? ''}`;
|
||||||
if (m.init_phase != null) s += m.init_phase >= 0 ? `+${m.init_phase}` : String(m.init_phase);
|
if (modulation.init_phase != null) s += modulation.init_phase >= 0 ? `+${modulation.init_phase}` : String(modulation.init_phase);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Basic properties ───────────────────────────────────────────────────────
|
|
||||||
// RFC §3.2.1.1: O, A, S, R, FM go directly in the variation MAPPING.
|
// 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) {
|
function _basicPropLines(bp) {
|
||||||
if (!bp) return [];
|
if (!bp) return [];
|
||||||
const lines = [];
|
const a = _serializeShape(bp.A), s = _serializeShape(bp.S), r = _serializeShape(bp.R);
|
||||||
if (bp.oscillator) lines.push(`O: ${bp.oscillator}`);
|
return [
|
||||||
const a = serializeShape(bp.A);
|
bp.oscillator ? `O: ${bp.oscillator}` : null,
|
||||||
if (a) lines.push(`A: "${a}"`);
|
a ? `A: "${a}"` : null,
|
||||||
const s = serializeShape(bp.S);
|
s ? `S: "${s}"` : null,
|
||||||
if (s) lines.push(`S: "${s}"`);
|
r ? `R: "${r}"` : null,
|
||||||
const r = serializeShape(bp.R);
|
...(bp.fmModulations ?? []).map(fm => `FM: "${_serializeModulation(fm)}"`),
|
||||||
if (r) lines.push(`R: "${r}"`);
|
...(bp.amModulations ?? []).map(am => `AM: "${_serializeModulation(am)}"`),
|
||||||
for (const fm of (bp.fmModulations ?? [])) lines.push(`FM: "${serializeModulation(fm)}"`);
|
].filter(Boolean);
|
||||||
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.
|
// 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) {
|
function _labelSpecLines(ls) {
|
||||||
const inner = basicPropLines(ls.basicProperties);
|
const inner = _basicPropLines(ls.basicProperties);
|
||||||
if (!inner.length) return [`${ls.label}:`];
|
if (!inner.length) return [`${ls.label}:`];
|
||||||
return [`${ls.label}:`, ...inner.map(l => ` ${l}`)];
|
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.
|
// RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level.
|
||||||
|
|
||||||
function variationLines(v) {
|
function _variationLines(variation) {
|
||||||
const lines = [];
|
const rc = _serializeShape(variation.railsbackCurve);
|
||||||
if (v.dependsOn) lines.push(`ATTR: ${v.dependsOn}`);
|
const vol = _serializeShape(variation.volumes);
|
||||||
lines.push(...basicPropLines(v.basicProperties));
|
const timbre = _serializeShape(variation.timbre);
|
||||||
for (const ls of (v.labelSpecs ?? [])) lines.push(...labelSpecLines(ls));
|
return [
|
||||||
if (v.spread?.length) lines.push(`SPREAD: [${v.spread.join(', ')}]`);
|
variation.dependsOn ? `ATTR: ${variation.dependsOn}` : null,
|
||||||
if (v.railsbackCurve) { const rc = serializeShape(v.railsbackCurve); if (rc) lines.push(`RAILSBACK_CURVE: "${rc}"`); }
|
..._basicPropLines(variation.basicProperties),
|
||||||
const vol = serializeShape(v.volumes);
|
...(variation.labelSpecs ?? []).flatMap(_labelSpecLines),
|
||||||
if (vol) lines.push(`VOLUMES: "${vol}"`);
|
variation.spread?.length ? `SPREAD: [${variation.spread.join(', ')}]` : null,
|
||||||
const timbre = serializeShape(v.timbre);
|
rc ? `RAILSBACK_CURVE: "${rc}"` : null,
|
||||||
if (timbre) lines.push(`TIMBRE: "${timbre}"`);
|
vol ? `VOLUMES: "${vol}"` : null,
|
||||||
for (const fm of (v.fmModulations ?? [])) lines.push(`FM: "${serializeModulation(fm)}"`);
|
timbre ? `TIMBRE: "${timbre}"` : null,
|
||||||
for (const am of (v.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
|
...(variation.fmModulations ?? []).map(fm => `FM: "${_serializeModulation(fm)}"`),
|
||||||
for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv));
|
...(variation.amModulations ?? []).map(am => `AM: "${_serializeModulation(am)}"`),
|
||||||
return lines;
|
...(variation.subvariations ?? []).flatMap(_variationLines),
|
||||||
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Instrument character block ─────────────────────────────────────────────
|
|
||||||
// VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser
|
// 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
|
// 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
|
// is implicit when no character: wrapper exists). Promote them into a synthetic
|
||||||
// root variation here so the export structure is RFC-correct.
|
// root variation here so the export structure is RFC-correct.
|
||||||
|
|
||||||
function instrCharacterLines(instr) {
|
function _instrCharacterLines(instr) {
|
||||||
const variations = instr.variations ?? [];
|
const variations = instr.variations ?? [];
|
||||||
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
|
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
|
||||||
(instr.fmModulations ?? []).length > 0 ||
|
(instr.fmModulations ?? []).length > 0 ||
|
||||||
@@ -110,63 +100,76 @@ function instrCharacterLines(instr) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (allVariations.length <= 1) {
|
if (allVariations.length <= 1) {
|
||||||
const vLines = allVariations.length ? variationLines(allVariations[0]) : [];
|
const vLines = allVariations.length ? _variationLines(allVariations[0]) : [];
|
||||||
return vLines.map(l => ` ${l}`);
|
return vLines.map(l => ` ${l}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
|
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
|
||||||
const result = [];
|
return allVariations.flatMap(v => {
|
||||||
for (const v of allVariations) {
|
const vLines = _variationLines(v);
|
||||||
const vLines = variationLines(v);
|
if (!vLines.length) return [];
|
||||||
if (!vLines.length) continue;
|
return [` - ${vLines[0]}`, ...vLines.slice(1).map(l => ` ${l}`)];
|
||||||
result.push(` - ${vLines[0]}`);
|
});
|
||||||
for (const l of vLines.slice(1)) result.push(` ${l}`);
|
}
|
||||||
}
|
|
||||||
return result;
|
// 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', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Instrument ────────────────────────────────────────────────────────────
|
|
||||||
// RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'"
|
// RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'"
|
||||||
|
|
||||||
export function exportInstrument(instr) {
|
export function exportInstrument(instr) {
|
||||||
const lines = [`instrument ${instr.name}:`];
|
const lines = [`instrument ${instr.name}:`];
|
||||||
if (instr.notChangedSince) lines.push(` NOT_CHANGED_SINCE: ${instr.notChangedSince}`);
|
lines.push(` NOT_CHANGED_SINCE: ${_nowISO()}`);
|
||||||
lines.push(` character:`);
|
lines.push(` character:`);
|
||||||
lines.push(...instrCharacterLines(instr));
|
lines.push(..._instrCharacterLines(instr));
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Articles ───────────────────────────────────────────────────────────────
|
|
||||||
// RFC §4.3: articles: MAPPING { LABEL: { ATTR: VALUE ... } ... }
|
// RFC §4.3: articles: MAPPING { LABEL: { ATTR: VALUE ... } ... }
|
||||||
|
|
||||||
function serializeArticleValue(v) {
|
function _serializeArticleValue(v) {
|
||||||
if (typeof v === 'boolean') return String(v);
|
if (typeof v === 'boolean') return String(v);
|
||||||
if (typeof v === 'number') return String(v);
|
if (typeof v === 'number') return String(v);
|
||||||
const s = String(v);
|
const s = String(v);
|
||||||
return /[:#\[\]{}&*!,|>'"%@`]/.test(s) ? JSON.stringify(s) : s;
|
return /[:#\[\]{}&*!,|>'"%@`]/.test(s) ? JSON.stringify(s) : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArticlesBlock(articles) {
|
function _buildArticlesBlock(articles) {
|
||||||
const lines = ['articles:'];
|
const body = articles.flatMap(art => {
|
||||||
for (const art of articles) {
|
const defaults = (art.properties ?? []).filter(p => p.scope !== 'overwrites' && p.name);
|
||||||
const props = (art.properties ?? []).filter(p => p.name);
|
const overwrites = (art.properties ?? []).filter(p => p.scope === 'overwrites' && p.name);
|
||||||
if (!props.length) continue;
|
if (!defaults.length && !overwrites.length) return [];
|
||||||
lines.push(` ${art.name}:`);
|
const lines = [` ${art.name}:`];
|
||||||
for (const p of props) lines.push(` ${p.name}: ${serializeArticleValue(p.value)}`);
|
defaults.forEach(p => lines.push(` ${p.name}: ${_serializeArticleValue(p.value)}`));
|
||||||
}
|
if (overwrites.length) {
|
||||||
return lines.length > 1 ? lines.join('\n') : null;
|
lines.push(` -important:`);
|
||||||
|
overwrites.forEach(p => lines.push(` - ${p.name}`));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
return body.length ? ['articles:', ...body].join('\n') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchArticles(text, articles) {
|
function _patchArticles(text, articles) {
|
||||||
const newBlock = buildArticlesBlock(articles);
|
const newBlock = _buildArticlesBlock(articles);
|
||||||
if (!newBlock) return text;
|
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const artIdx = lines.findIndex(l => /^articles\s*:/.test(l));
|
const artIdx = lines.findIndex(l => /^articles\s*:/.test(l));
|
||||||
if (artIdx !== -1) {
|
if (artIdx !== -1) {
|
||||||
let end = artIdx + 1;
|
let end = artIdx + 1;
|
||||||
while (end < lines.length && (lines[end] === '' || lines[end].startsWith(' ') || lines[end].startsWith('\t'))) end++;
|
while (end < lines.length && (lines[end] === '' || lines[end].startsWith(' ') || lines[end].startsWith('\t'))) end++;
|
||||||
lines.splice(artIdx, end - artIdx, newBlock);
|
if (newBlock) lines.splice(artIdx, end - artIdx, newBlock);
|
||||||
} else {
|
else lines.splice(artIdx, end - artIdx);
|
||||||
|
} else if (newBlock) {
|
||||||
const instrIdx = lines.findIndex(l => /^instrument\s/.test(l));
|
const instrIdx = lines.findIndex(l => /^instrument\s/.test(l));
|
||||||
const insertAt = instrIdx !== -1 ? instrIdx : lines.length;
|
const insertAt = instrIdx !== -1 ? instrIdx : lines.length;
|
||||||
lines.splice(insertAt, 0, newBlock, '');
|
lines.splice(insertAt, 0, newBlock, '');
|
||||||
@@ -174,24 +177,18 @@ function patchArticles(text, articles) {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Score patch ────────────────────────────────────────────────────────────
|
|
||||||
// Replace dirty article, instrument, and bar _meta blocks.
|
|
||||||
// Voice note content in bar documents is left verbatim.
|
// Voice note content in bar documents is left verbatim.
|
||||||
|
|
||||||
const META_KEYS = ['title', 'composer', 'source', 'encrypter'];
|
const META_KEYS = ['title', 'composer', 'source', 'encrypter'];
|
||||||
|
|
||||||
function patchMetadata(text, info) {
|
function _patchMetadata(text, info) {
|
||||||
if (!info) return text;
|
if (!info) return text;
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const replaced = new Set();
|
const replaced = new Set();
|
||||||
|
|
||||||
const out = lines.map(line => {
|
const out = lines.map(line => {
|
||||||
for (const key of META_KEYS) {
|
const key = META_KEYS.find(k => line.startsWith(k + ':') && info[k] != null && info[k] !== '');
|
||||||
if (line.startsWith(key + ':') && info[key] != null && info[key] !== '') {
|
if (key) { replaced.add(key); return `${key}: ${info[key]}`; }
|
||||||
replaced.add(key);
|
|
||||||
return `${key}: ${info[key]}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return line;
|
return line;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,14 +200,14 @@ function patchMetadata(text, info) {
|
|||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchInstrumentHeader(text, instruments) {
|
function _patchInstrumentHeader(text, instruments) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const result = [];
|
const result = [];
|
||||||
const instrMap = {};
|
const instrMap = Object.fromEntries(instruments.flatMap(instr => {
|
||||||
for (const instr of instruments) {
|
const entries = [[instr.name, instr]];
|
||||||
instrMap[instr.name] = instr;
|
if (instr.name.includes('/')) entries.push([instr.name.split('/').pop(), instr]);
|
||||||
if (instr.name.includes('/')) instrMap[instr.name.split('/').pop()] = instr;
|
return entries;
|
||||||
}
|
}));
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
@@ -219,17 +216,25 @@ function patchInstrumentHeader(text, instruments) {
|
|||||||
if (m) {
|
if (m) {
|
||||||
const rawName = m[1].replace(/^'|'$/g, '');
|
const rawName = m[1].replace(/^'|'$/g, '');
|
||||||
const instr = instrMap[rawName];
|
const instr = instrMap[rawName];
|
||||||
if (instr && instr.deleted) {
|
if (!instr) {
|
||||||
|
// not in model → deleted; skip block
|
||||||
i++;
|
i++;
|
||||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||||
} else if (instr && instr.isDirty) {
|
} 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++;
|
i++;
|
||||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||||
result.push(exportInstrument(instr));
|
result.push(exportInstrument(instr));
|
||||||
result.push('');
|
result.push('');
|
||||||
} else {
|
|
||||||
result.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.push(line);
|
result.push(line);
|
||||||
@@ -241,17 +246,77 @@ function patchInstrumentHeader(text, instruments) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function patchBarMeta(doc, bar) {
|
// 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 props = [];
|
||||||
const sp = stressorToString(bar.stressor);
|
const sp = stressorToString(bar.stressor);
|
||||||
if (sp) props.push(` stress_pattern: ${sp}`);
|
if (sp) props.push(` stress_pattern: ${sp}`);
|
||||||
if (bar.tempoLevels != null)
|
if (bar.tempoLevels != null)
|
||||||
props.push(` beats_per_minute: ${bar.tempoLevels}`);
|
props.push(` beats_per_minute: ${bar.tempoLevels}`);
|
||||||
const ub = serializeShape(bar.upperStressBound);
|
const ub = _serializeShape(bar.upperStressBound);
|
||||||
if (ub) props.push(` upper_stress_bound: ${ub}`);
|
if (ub) props.push(` upper_stress_bound: ${ub}`);
|
||||||
const lb = serializeShape(bar.lowerStressBound);
|
const lb = _serializeShape(bar.lowerStressBound);
|
||||||
if (lb) props.push(` lower_stress_bound: ${lb}`);
|
if (lb) props.push(` lower_stress_bound: ${lb}`);
|
||||||
if (bar.tempoShape) { const ts = serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
|
if (bar.tempoShape) { const ts = _serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
|
||||||
|
|
||||||
const lines = doc.split('\n');
|
const lines = doc.split('\n');
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -278,7 +343,7 @@ function patchBarMeta(doc, bar) {
|
|||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNewBarDoc(bar) {
|
function _buildNewBarDoc(bar) {
|
||||||
const lines = [`_id: ${bar.id}`, '_meta:'];
|
const lines = [`_id: ${bar.id}`, '_meta:'];
|
||||||
const sp = stressorToString(bar.stressor);
|
const sp = stressorToString(bar.stressor);
|
||||||
if (sp) lines.push(` stress_pattern: ${sp}`);
|
if (sp) lines.push(` stress_pattern: ${sp}`);
|
||||||
@@ -286,55 +351,46 @@ function buildNewBarDoc(bar) {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = [], flags = {}) {
|
export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = []) {
|
||||||
const log = [];
|
const log = [];
|
||||||
const SEP = '\n---\n';
|
const SEP = '\n---\n';
|
||||||
const [header, ...barDocs] = rawScoreText.split(SEP);
|
const [header, ...barDocs] = rawScoreText.split(SEP);
|
||||||
|
|
||||||
let patchedHeader = patchMetadata(header, info);
|
let patchedHeader = _patchMetadata(header, info);
|
||||||
|
|
||||||
const dirtyArticles = articles.filter(a => a.isDirty);
|
if (articles.length) {
|
||||||
if (dirtyArticles.length || flags.articlesModified) {
|
patchedHeader = _patchArticles(patchedHeader, articles);
|
||||||
patchedHeader = patchArticles(patchedHeader, articles);
|
articles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` }));
|
||||||
for (const a of dirtyArticles) log.push({ level: 'changed', path: `articles / ${a.name}` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
patchedHeader = patchInstrumentHeader(patchedHeader, instruments);
|
patchedHeader = _patchInstrumentHeader(patchedHeader, instruments);
|
||||||
for (const i of instruments) {
|
patchedHeader = _patchStageSection(patchedHeader, instruments);
|
||||||
if (i.deleted) log.push({ level: 'changed', path: `instrument / ${i.name} (deleted)` });
|
instruments.forEach(i => {
|
||||||
else if (i.isDirty) log.push({ level: 'changed', path: `instrument / ${i.name}` });
|
if (!(i.isLinked && !i._modified)) log.push({ level: 'changed', path: `instrument / ${i.name}` });
|
||||||
}
|
});
|
||||||
|
|
||||||
const barMap = {};
|
const barMap = Object.fromEntries(bars.map(b => [b.id, b]));
|
||||||
for (const bar of bars) barMap[bar.id] = bar;
|
const newBars = bars.filter(b => b._isNew);
|
||||||
|
|
||||||
if (!barDocs.length) {
|
if (!barDocs.length) {
|
||||||
const newBarDocs = bars.filter(b => b.isNew && !b.deleted).map(b => buildNewBarDoc(b));
|
newBars.forEach(b => log.push({ level: 'changed', path: `bar / ${b.id}` }));
|
||||||
for (const b of bars.filter(b => b.isNew && !b.deleted)) log.push({ level: 'changed', path: `bar / ${b.id}` });
|
return { text: [patchedHeader, ...newBars.map(_buildNewBarDoc)].join(SEP), log };
|
||||||
return { text: [patchedHeader, ...newBarDocs].join(SEP), log };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let passedThrough = 0;
|
|
||||||
const patchedBarDocs = [];
|
const patchedBarDocs = [];
|
||||||
for (const doc of barDocs) {
|
barDocs.forEach(doc => {
|
||||||
const m = doc.match(/^_id:\s*(\S+)/m);
|
const m = doc.match(/^_id:\s*(\S+)/m);
|
||||||
if (!m) { passedThrough++; patchedBarDocs.push(doc); continue; }
|
if (!m) { patchedBarDocs.push(doc); return; }
|
||||||
const bar = barMap[m[1]];
|
const bar = barMap[m[1]];
|
||||||
if (!bar) { passedThrough++; patchedBarDocs.push(doc); continue; }
|
if (!bar) return; // not in model → deleted
|
||||||
if (bar.deleted) continue;
|
|
||||||
if (!bar.isDirty) { passedThrough++; patchedBarDocs.push(doc); continue; }
|
|
||||||
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
||||||
patchedBarDocs.push(patchBarMeta(doc, bar));
|
patchedBarDocs.push(_patchBarMeta(doc, bar));
|
||||||
}
|
});
|
||||||
|
|
||||||
for (const bar of bars.filter(b => b.isNew && !b.deleted)) {
|
newBars.forEach(bar => {
|
||||||
patchedBarDocs.push(buildNewBarDoc(bar));
|
patchedBarDocs.push(_buildNewBarDoc(bar));
|
||||||
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
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 };
|
return { text: [patchedHeader, ...patchedBarDocs].join(SEP), log };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,10 @@ export const store = reactive({
|
|||||||
markDirty() {
|
markDirty() {
|
||||||
this.isDirty = true;
|
this.isDirty = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetEditState() {
|
||||||
|
this.isDirty = false;
|
||||||
|
this.exportLog = [];
|
||||||
|
this.focusPath = [];
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
// Group a node's sub-objects by KIND (the SLOT side of SLOT.SUBTYPE in the AST).
|
|
||||||
// Per the editor design: items sharing a kind share one pane; different kinds
|
|
||||||
// produce separate panes whose handles render in the AppShell bottom bar.
|
|
||||||
// Each group is { kind, items: [{ kind, node, label, hasChildren, readOnly? }] }.
|
|
||||||
// The returned order is the display order for the handle bar.
|
|
||||||
|
|
||||||
export const KIND_LABEL = {
|
export const KIND_LABEL = {
|
||||||
tuning: 'TU',
|
tuning: 'TU',
|
||||||
stage: 'ST',
|
stage: 'ST',
|
||||||
@@ -30,10 +24,10 @@ export function getKindGroups(node) {
|
|||||||
]});
|
]});
|
||||||
}
|
}
|
||||||
|
|
||||||
const stage = [];
|
const stage = [
|
||||||
if (node.stageCone) stage.push({ kind: 'stage', node: node.stageCone, label: 'cone (orchestra)', hasChildren: false });
|
...(node.stageCone ? [{ kind: 'stage', node: node.stageCone, label: 'cone (orchestra)', hasChildren: false }] : []),
|
||||||
for (const sv of (node.stageVoices ?? []))
|
...(node.stageVoices ?? []).map(sv => ({ kind: 'stage', node: sv, label: sv.name, hasChildren: false })),
|
||||||
stage.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false });
|
];
|
||||||
if (stage.length) groups.push({ kind: 'stage', items: stage });
|
if (stage.length) groups.push({ kind: 'stage', items: stage });
|
||||||
|
|
||||||
if (node.instruments.length) {
|
if (node.instruments.length) {
|
||||||
|
|||||||
217
test-parser.mjs
217
test-parser.mjs
@@ -55,9 +55,10 @@ ok('articles[0].properties[]', Array.isArray(model.articles[0].properties)
|
|||||||
const fProp = model.articles[0].properties.find(p => p.name === 'add_stress');
|
const fProp = model.articles[0].properties.find(p => p.name === 'add_stress');
|
||||||
ok('articles[0] add_stress prop', fProp && fProp.value === 3 && fProp.scope === 'defaults');
|
ok('articles[0] add_stress prop', fProp && fProp.value === 3 && fProp.scope === 'defaults');
|
||||||
|
|
||||||
// Merge across subtypes: same label, different subtypes → one entry, multi-scope props
|
// Article slot structure: stage.article with article.defaults / article.overwrites children
|
||||||
const MERGE_FIXTURE = `01 articles.defaults 'g' add_stress=2
|
const MERGE_FIXTURE = `01 stage.article 'g'
|
||||||
01 articles.overwrites 'g' pitch_bend=0.05
|
02 article.defaults 'add_stress' constant=2
|
||||||
|
02 article.overwrites 'pitch_bend'
|
||||||
`;
|
`;
|
||||||
const mergeRoot = parseAstLog('00 tuning base=\'x\'\n' + MERGE_FIXTURE);
|
const mergeRoot = parseAstLog('00 tuning base=\'x\'\n' + MERGE_FIXTURE);
|
||||||
const mergeModel = buildModel(mergeRoot);
|
const mergeModel = buildModel(mergeRoot);
|
||||||
@@ -67,7 +68,33 @@ ok('merge: 2 properties', mergeModel.articles[0].properties.length
|
|||||||
const gDef = mergeModel.articles[0].properties.find(p => p.scope === 'defaults');
|
const gDef = mergeModel.articles[0].properties.find(p => p.scope === 'defaults');
|
||||||
const gOver = mergeModel.articles[0].properties.find(p => p.scope === 'overwrites');
|
const gOver = mergeModel.articles[0].properties.find(p => p.scope === 'overwrites');
|
||||||
ok('merge: default scope present', gDef && gDef.name === 'add_stress' && gDef.value === 2);
|
ok('merge: default scope present', gDef && gDef.name === 'add_stress' && gDef.value === 2);
|
||||||
ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === 0.05);
|
ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === null);
|
||||||
|
|
||||||
|
section('article.definite on stem_note');
|
||||||
|
const DEF_ART = `00 bar '001P1L1M1'
|
||||||
|
01 bar.voice 'pi'
|
||||||
|
02 voice.offset tick=0
|
||||||
|
03 line.stem_note pitch='G5' articulatory='tailed'
|
||||||
|
04 article.definite 'tailed' constant=3
|
||||||
|
04 stem_note.chain _tmp_string='o'
|
||||||
|
05 chain.clause 0
|
||||||
|
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||||
|
`;
|
||||||
|
const defArtModel = buildModel(parseAstLog(DEF_ART));
|
||||||
|
const defSN = defArtModel.bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||||
|
ok('definite prop flattened onto stem_note', defSN.tailed === 3);
|
||||||
|
ok('articulatory not in unknownProps', defSN.unknownProps?.articulatory === undefined);
|
||||||
|
|
||||||
|
section('stem_note weight');
|
||||||
|
const WEIGHT_SN = `00 bar '001P1L1M1'
|
||||||
|
01 bar.voice 'pi'
|
||||||
|
02 voice.offset tick=0
|
||||||
|
03 line.stem_note pitch='C4' weight=0.8
|
||||||
|
`;
|
||||||
|
const weightModel = buildModel(parseAstLog(WEIGHT_SN));
|
||||||
|
const weightSN = weightModel.bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||||
|
ok('weight parsed', weightSN.weight === 0.8);
|
||||||
|
ok('weight null when absent', defSN.weight === null);
|
||||||
|
|
||||||
// ── Bar IDs ────────────────────────────────────────────────────────────────
|
// ── Bar IDs ────────────────────────────────────────────────────────────────
|
||||||
// Bar IDs are opaque auto-increment strings; only the raw id string matters.
|
// Bar IDs are opaque auto-increment strings; only the raw id string matters.
|
||||||
@@ -230,8 +257,8 @@ const SCORE_FIXTURE = new URL('./fixtures/pathetique.spls', import.meta.url);
|
|||||||
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
|
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
|
||||||
|
|
||||||
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
||||||
// Mark only alpha as dirty, verify ki is preserved verbatim.
|
// All non-linked instruments are always re-serialized; linked+unmodified are passed through.
|
||||||
const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' }));
|
const patchInstruments = model.instruments.map(i => ({ ...i }));
|
||||||
const { text: patched, log: patchLog } = patchScore(rawScore, patchInstruments);
|
const { text: patched, log: patchLog } = patchScore(rawScore, patchInstruments);
|
||||||
const patchedLines = patched.split('\n');
|
const patchedLines = patched.split('\n');
|
||||||
|
|
||||||
@@ -239,16 +266,13 @@ ok('patched score still has instrument alpha:', patchedLines.some(l => /^instrum
|
|||||||
ok('patched score still has instrument ki:', patchedLines.some(l => /^instrument\s+ki\s*:/.test(l)));
|
ok('patched score still has instrument ki:', patchedLines.some(l => /^instrument\s+ki\s*:/.test(l)));
|
||||||
ok('patched score alpha block contains character:', patchedLines.some(l => l.trim() === 'character:'));
|
ok('patched score alpha block contains character:', patchedLines.some(l => l.trim() === 'character:'));
|
||||||
ok('patchScore log records alpha as changed', patchLog.some(e => e.level === 'changed' && e.path === 'instrument / alpha'));
|
ok('patchScore log records alpha as changed', patchLog.some(e => e.level === 'changed' && e.path === 'instrument / alpha'));
|
||||||
ok('patchScore log does not record ki (not dirty)', !patchLog.some(e => e.path?.includes('ki')));
|
ok('patchScore log records ki as changed', patchLog.some(e => e.path?.includes('ki')));
|
||||||
|
|
||||||
// Ki is clean — its block must appear verbatim (check a unique line from the original)
|
// dev/piano is linked and unmodified — it should pass through verbatim
|
||||||
const kiOrigLines = rawScore.split('\n').filter(l => l.startsWith('instrument ki:') || (l.startsWith(' ') && rawScore.indexOf('instrument ki:') < rawScore.indexOf(l)));
|
const pianoInstr = patchInstruments.find(i => i.name === 'dev/piano');
|
||||||
// Simpler: original ki block should still exist in patched
|
if (pianoInstr) {
|
||||||
const kiOrigIdx = rawScore.indexOf('\ninstrument ki:');
|
ok('dev/piano is linked', pianoInstr.isLinked === true);
|
||||||
const kiBlock = kiOrigIdx >= 0 ? rawScore.slice(kiOrigIdx + 1, rawScore.indexOf('\ninstrument ', kiOrigIdx + 1) >>> 0 || undefined) : '';
|
ok('dev/piano not in log (linked+unmodified)', !patchLog.some(e => e.path?.includes('dev/piano')));
|
||||||
if (kiBlock) {
|
|
||||||
const firstKiLine = kiBlock.split('\n')[0];
|
|
||||||
ok('ki block preserved verbatim (first line)', patched.includes(firstKiLine));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// patched score must not be empty and must be shorter or same length as original + alpha export
|
// patched score must not be empty and must be shorter or same length as original + alpha export
|
||||||
@@ -421,7 +445,6 @@ voice soprano:
|
|||||||
|
|
||||||
const dirtyBar = {
|
const dirtyBar = {
|
||||||
id: '001P1L1M1',
|
id: '001P1L1M1',
|
||||||
isDirty: true,
|
|
||||||
stressor: { groups: [[2, 3], [1]] },
|
stressor: { groups: [[2, 3], [1]] },
|
||||||
tempoLevels: 140,
|
tempoLevels: 140,
|
||||||
upperStressBound: null,
|
upperStressBound: null,
|
||||||
@@ -430,8 +453,7 @@ const dirtyBar = {
|
|||||||
};
|
};
|
||||||
const cleanBar = {
|
const cleanBar = {
|
||||||
id: '001P1L1M2',
|
id: '001P1L1M2',
|
||||||
isDirty: false,
|
stressor: null, tempoLevels: 100,
|
||||||
stressor: null, tempoLevels: null,
|
|
||||||
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
||||||
};
|
};
|
||||||
const { text: barPatched, log: barLog } = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
|
const { text: barPatched, log: barLog } = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
|
||||||
@@ -439,11 +461,15 @@ const barPatchedLines = barPatched.split('\n');
|
|||||||
ok('dirty bar _meta updated with new BPM', barPatched.includes('beats_per_minute: 140'));
|
ok('dirty bar _meta updated with new BPM', barPatched.includes('beats_per_minute: 140'));
|
||||||
ok('dirty bar stress_pattern updated', barPatched.includes('stress_pattern: 2,3;1'));
|
ok('dirty bar stress_pattern updated', barPatched.includes('stress_pattern: 2,3;1'));
|
||||||
ok('dirty bar voice content preserved', barPatched.includes('- C4 4'));
|
ok('dirty bar voice content preserved', barPatched.includes('- C4 4'));
|
||||||
ok('clean bar unchanged', barPatched.includes('beats_per_minute: 100'));
|
ok('clean bar BPM preserved via model', barPatched.includes('beats_per_minute: 100'));
|
||||||
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
|
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
|
||||||
ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2);
|
ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2);
|
||||||
ok('bar log records dirty bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M1'));
|
ok('bar log records dirty bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M1'));
|
||||||
ok('bar log has pass-through info entry', barLog.some(e => e.level === 'info' && e.message?.includes('passed through')));
|
ok('bar log records clean bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M2'));
|
||||||
|
|
||||||
|
// Minimal instrument stub reused across patchScore tests that don't care about instrument content.
|
||||||
|
const STUB_ALPHA = { name: 'alpha', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] };
|
||||||
|
const STUB_KI = { name: 'ki', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] };
|
||||||
|
|
||||||
// ── patchScore metadata ────────────────────────────────────────────────────
|
// ── patchScore metadata ────────────────────────────────────────────────────
|
||||||
section('patchScore metadata');
|
section('patchScore metadata');
|
||||||
@@ -456,26 +482,26 @@ instrument alpha:
|
|||||||
A: "1:0,10;1,0"
|
A: "1:0,10;1,0"
|
||||||
`;
|
`;
|
||||||
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
|
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
|
||||||
const { text: metaPatched } = patchScore(META_SCORE, [], [], updatedInfo);
|
const { text: metaPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], updatedInfo);
|
||||||
ok('existing title replaced', metaPatched.includes('title: New Title'));
|
ok('existing title replaced', metaPatched.includes('title: New Title'));
|
||||||
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
|
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
|
||||||
ok('empty value leaves existing line', metaPatched.includes('source: Some Book'));
|
ok('empty value leaves existing line', metaPatched.includes('source: Some Book'));
|
||||||
ok('new key prepended', metaPatched.includes('encrypter: Me'));
|
ok('new key prepended', metaPatched.includes('encrypter: Me'));
|
||||||
ok('instrument block untouched', metaPatched.includes('instrument alpha:'));
|
ok('instrument block present', metaPatched.includes('instrument alpha:'));
|
||||||
|
|
||||||
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
|
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
|
||||||
const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' });
|
const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [STUB_KI], [], { title: 'Fugue', composer: 'Bach' });
|
||||||
ok('missing title prepended', noTitlePatched.includes('title: Fugue'));
|
ok('missing title prepended', noTitlePatched.includes('title: Fugue'));
|
||||||
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
|
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
|
||||||
|
|
||||||
const { text: nullInfoPatched } = patchScore(META_SCORE, [], [], null);
|
const { text: nullInfoPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], null);
|
||||||
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
|
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
|
||||||
|
|
||||||
// ── patchScore articles ────────────────────────────────────────────────────
|
// ── patchScore articles ────────────────────────────────────────────────────
|
||||||
section('patchScore articles');
|
section('patchScore articles');
|
||||||
const ART_SCORE = `articles:\n f: { add_stress: 3 }\n\ninstrument alpha:\n character:\n`;
|
const ART_SCORE = `articles:\n f: { add_stress: 3 }\n\ninstrument alpha:\n character:\n`;
|
||||||
const artModel = [{ name: 'f', isDirty: true, properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }];
|
const artModel = [{ name: 'f', properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }];
|
||||||
const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [], [], null, artModel);
|
const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [STUB_ALPHA], [], null, artModel);
|
||||||
ok('article block replaced', artPatched.includes('articles:'));
|
ok('article block replaced', artPatched.includes('articles:'));
|
||||||
ok('updated value written', artPatched.includes('add_stress: 4'));
|
ok('updated value written', artPatched.includes('add_stress: 4'));
|
||||||
ok('old flow-style entry removed', !artPatched.includes('{ add_stress: 3 }'));
|
ok('old flow-style entry removed', !artPatched.includes('{ add_stress: 3 }'));
|
||||||
@@ -483,49 +509,112 @@ ok('instrument line still present', artPatched.includes('instrument alpha:'));
|
|||||||
ok('article change logged', artLog.some(e => e.level === 'changed' && e.path === 'articles / f'));
|
ok('article change logged', artLog.some(e => e.level === 'changed' && e.path === 'articles / f'));
|
||||||
|
|
||||||
const ART_SCORE_NO_ART = `instrument alpha:\n character:\n`;
|
const ART_SCORE_NO_ART = `instrument alpha:\n character:\n`;
|
||||||
const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [], [], null, artModel);
|
const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [STUB_ALPHA], [], null, artModel);
|
||||||
ok('article block inserted before instrument when missing', artInserted.indexOf('articles:') < artInserted.indexOf('instrument alpha:'));
|
ok('article block inserted before instrument when missing', artInserted.indexOf('articles:') < artInserted.indexOf('instrument alpha:'));
|
||||||
|
|
||||||
const artClean = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 3, scope: 'defaults' }] }];
|
// ── patchScore articles with overwrites ───────────────────────────────────
|
||||||
const { text: artUntouched } = patchScore(ART_SCORE, [], [], null, artClean);
|
section('patchScore articles with overwrites');
|
||||||
ok('clean articles section left verbatim', artUntouched.includes('{ add_stress: 3 }'));
|
const OVW_SCORE = `articles:\n f:\n add_stress: 3\n\ninstrument alpha:\n character:\n`;
|
||||||
|
const ovwModel = [{ name: 'f', properties: [
|
||||||
// ── patchScore articlesModified flag ──────────────────────────────────────
|
{ name: 'add_stress', value: 4, scope: 'defaults' },
|
||||||
section('patchScore articlesModified flag');
|
{ name: 'add_stress', scope: 'overwrites' },
|
||||||
const artCleanForFlag = [{ name: 'f', isDirty: false, properties: [{ name: 'add_stress', value: 99, scope: 'defaults' }] }];
|
]}];
|
||||||
const { text: artFlagPatched, log: artFlagLog } = patchScore(ART_SCORE, [], [], null, artCleanForFlag, { articlesModified: true });
|
const { text: ovwPatched } = patchScore(OVW_SCORE, [STUB_ALPHA], [], null, ovwModel);
|
||||||
ok('articlesModified forces re-serialization', artFlagPatched.includes('add_stress: 99'));
|
ok('overwrites emit -important block', ovwPatched.includes('-important:'));
|
||||||
ok('articlesModified: old flow-style replaced', !artFlagPatched.includes('{ add_stress: 3 }'));
|
ok('overwrites list entry present', ovwPatched.includes('- add_stress'));
|
||||||
|
|
||||||
// ── patchScore deleted bars ────────────────────────────────────────────────
|
// ── patchScore deleted bars ────────────────────────────────────────────────
|
||||||
section('patchScore deleted bars');
|
section('patchScore deleted bars');
|
||||||
const delBar1 = { id: '001P1L1M1', isDirty: true, deleted: true, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
const delBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||||
const delBar2 = { id: '001P1L1M2', isDirty: false, stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar2]);
|
||||||
const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar1, delBar2]);
|
|
||||||
ok('deleted bar removed from output', !delBarPatched.includes('_id: 001P1L1M1'));
|
ok('deleted bar removed from output', !delBarPatched.includes('_id: 001P1L1M1'));
|
||||||
ok('non-deleted bar preserved', delBarPatched.includes('_id: 001P1L1M2'));
|
ok('non-deleted bar preserved', delBarPatched.includes('_id: 001P1L1M2'));
|
||||||
ok('deleted bar reduces document count', (delBarPatched.match(/\n---\n/g) ?? []).length === 1);
|
ok('deleted bar reduces document count', (delBarPatched.match(/\n---\n/g) ?? []).length === 1);
|
||||||
|
|
||||||
// ── patchScore new bars ────────────────────────────────────────────────────
|
// ── patchScore new bars ────────────────────────────────────────────────────
|
||||||
section('patchScore new bars');
|
section('patchScore new bars');
|
||||||
const newBarEntry = { id: '001P1L1M3', isDirty: true, isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
const existingBar1 = { id: '001P1L1M1', stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||||
const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [newBarEntry]);
|
const existingBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: 100, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||||
|
const newBarEntry = { id: '001P1L1M3', _isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||||
|
const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [existingBar1, existingBar2, newBarEntry]);
|
||||||
ok('new bar appended to output', newBarPatched.includes('_id: 001P1L1M3'));
|
ok('new bar appended to output', newBarPatched.includes('_id: 001P1L1M3'));
|
||||||
ok('new bar has _meta with BPM', newBarPatched.includes('beats_per_minute: 120'));
|
ok('new bar has _meta with BPM', newBarPatched.includes('beats_per_minute: 120'));
|
||||||
ok('new bar appended as extra document', (newBarPatched.match(/\n---\n/g) ?? []).length === 3);
|
ok('new bar appended as extra document', (newBarPatched.match(/\n---\n/g) ?? []).length === 3);
|
||||||
ok('new bar logged as changed', newBarLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M3'));
|
ok('new bar logged as changed', newBarLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M3'));
|
||||||
|
|
||||||
|
// ── NOT_CHANGED_SINCE epoch → ISO conversion ──────────────────────────────
|
||||||
|
section('NOT_CHANGED_SINCE: epoch converted on passthrough, now on re-serialize');
|
||||||
|
const ncsScore = `instrument linked/pi:\n NOT_CHANGED_SINCE: 1710000000.0\n character:\n A: "1:0,10;1,0"\n\ninstrument embedded:\n NOT_CHANGED_SINCE: 1710000000.0\n character:\n A: "1:0,5;1,0"\n`;
|
||||||
|
const ncsList = [
|
||||||
|
{ name: 'linked/pi', isLinked: true, _modified: false, notChangedSince: 1710000000.0,
|
||||||
|
variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||||
|
{ name: 'embedded', isLinked: false, _modified: true, notChangedSince: 1710000000.0,
|
||||||
|
variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||||
|
];
|
||||||
|
const { text: ncsPatched } = patchScore(ncsScore, ncsList);
|
||||||
|
const ISO_RX = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/;
|
||||||
|
const ncsLines = ncsPatched.split('\n');
|
||||||
|
const linkedNCS = ncsLines.find(l => /NOT_CHANGED_SINCE/.test(l) && ncsLines.indexOf(l) < ncsLines.findIndex(l2 => /instrument embedded/.test(l2)));
|
||||||
|
const embeddedNCS = ncsLines.find((l, i) => /NOT_CHANGED_SINCE/.test(l) && i > ncsLines.findIndex(l2 => /instrument embedded/.test(l2)));
|
||||||
|
ok('linked passthrough: epoch converted to ISO date', linkedNCS && ISO_RX.test(linkedNCS) && !linkedNCS.includes('1710000000'));
|
||||||
|
ok('modified instrument: NOT_CHANGED_SINCE set to now', embeddedNCS && ISO_RX.test(embeddedNCS) && !embeddedNCS.includes('1710000000'));
|
||||||
|
|
||||||
// ── patchScore deleted instrument ─────────────────────────────────────────
|
// ── patchScore deleted instrument ─────────────────────────────────────────
|
||||||
section('patchScore deleted instrument');
|
section('patchScore deleted instrument');
|
||||||
const delInstrScore = `instrument alpha:\n character:\n A: "1:0,10;1,0"\n\ninstrument ki:\n character:\n A: "1:0,5;1,0"\n`;
|
const delInstrScore = `instrument alpha:\n character:\n A: "1:0,10;1,0"\n\ninstrument ki:\n character:\n A: "1:0,5;1,0"\n`;
|
||||||
const delInstrList = [
|
const delInstrList = [
|
||||||
{ name: 'alpha', deleted: true, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
{ name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||||
{ name: 'ki', deleted: false, isDirty: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
|
||||||
];
|
];
|
||||||
const { text: delInstrPatched, log: delInstrLog } = patchScore(delInstrScore, delInstrList);
|
const { text: delInstrPatched, log: delInstrLog } = patchScore(delInstrScore, delInstrList);
|
||||||
ok('deleted instrument removed from output', !delInstrPatched.includes('instrument alpha:'));
|
ok('deleted instrument removed from output', !delInstrPatched.includes('instrument alpha:'));
|
||||||
ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:'));
|
ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:'));
|
||||||
ok('deleted instrument logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('alpha') && e.path?.includes('deleted')));
|
ok('ki re-serialized and logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('ki')));
|
||||||
|
|
||||||
|
// ── patchScore stage section cleanup ─────────────────────────────────────
|
||||||
|
section('patchScore stage section: deleted instrument removes voice entry');
|
||||||
|
// pi → mapping form, instrument: dev/piano (present) → keep
|
||||||
|
// ki → mapping form, instrument: alpha (deleted) → remove
|
||||||
|
// alpha → string form, implicit name=alpha (deleted) → remove
|
||||||
|
const stageScore = [
|
||||||
|
'stage:',
|
||||||
|
' pi:',
|
||||||
|
' direction: 1|1',
|
||||||
|
' distance: 0',
|
||||||
|
' instrument: dev/piano',
|
||||||
|
' ki:',
|
||||||
|
' direction: 2|1',
|
||||||
|
' distance: 1',
|
||||||
|
' instrument: alpha',
|
||||||
|
' alpha: 2|3 0',
|
||||||
|
'',
|
||||||
|
'instrument dev/piano:',
|
||||||
|
' character:',
|
||||||
|
' A: "1:0,10;1,0"',
|
||||||
|
].join('\n');
|
||||||
|
const stageInstrList = [
|
||||||
|
{ name: 'dev/piano', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||||
|
];
|
||||||
|
const { text: stagePatched } = patchScore(stageScore, stageInstrList);
|
||||||
|
ok('voice with explicit deleted instrument (mapping form) removed', !stagePatched.includes(' ki:'));
|
||||||
|
ok('voice with implicit deleted name (string form) removed', !stagePatched.includes(' alpha:'));
|
||||||
|
ok('voice with explicit surviving instrument (mapping form) kept', stagePatched.includes(' pi:'));
|
||||||
|
ok('instrument dev/piano block re-serialized', stagePatched.includes('instrument dev/piano:'));
|
||||||
|
|
||||||
|
const stageScoreKeep = [
|
||||||
|
'stage:',
|
||||||
|
' pi:',
|
||||||
|
' direction: 1|1',
|
||||||
|
' distance: 0',
|
||||||
|
' instrument: dev/piano',
|
||||||
|
' ki: 2|1 1',
|
||||||
|
].join('\n');
|
||||||
|
const stageInstrKeep = [
|
||||||
|
{ name: 'dev/piano', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||||
|
{ name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||||
|
];
|
||||||
|
const { text: stageKeptPatched } = patchScore(stageScoreKeep, stageInstrKeep);
|
||||||
|
ok('retained voice with explicit instrument key kept', stageKeptPatched.includes(' pi:'));
|
||||||
|
ok('retained voice with implicit instrument name kept', stageKeptPatched.includes(' ki:'));
|
||||||
|
|
||||||
// ── Motif parsing ─────────────────────────────────────────────────────────
|
// ── Motif parsing ─────────────────────────────────────────────────────────
|
||||||
section('Motif parsing — dynamic motif');
|
section('Motif parsing — dynamic motif');
|
||||||
@@ -535,8 +624,8 @@ const DYN_MOTIF = `00 bar '001P1L1M1'
|
|||||||
03 line.stem_note pitch=0
|
03 line.stem_note pitch=0
|
||||||
04 stem_note.chain _tmp_string='oo'
|
04 stem_note.chain _tmp_string='oo'
|
||||||
05 chain.clause 0 repeat=1
|
05 chain.clause 0 repeat=1
|
||||||
06 seq.note letter='o' shift=0 length=1 netlength=1
|
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||||
06 seq.note letter='o' shift=12 netlength=1 length=1
|
06 seq.note letters='o' shift=12 netlength=1 length=1
|
||||||
`;
|
`;
|
||||||
const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0];
|
const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0];
|
||||||
const dynVoice = dynBar.voices['pi'];
|
const dynVoice = dynBar.voices['pi'];
|
||||||
@@ -554,7 +643,7 @@ const STAT_MOTIF = `00 bar '001P1L1M1'
|
|||||||
03 line.stem_note pitch='C4'
|
03 line.stem_note pitch='C4'
|
||||||
04 stem_note.chain _tmp_string='o'
|
04 stem_note.chain _tmp_string='o'
|
||||||
05 chain.clause 0
|
05 chain.clause 0
|
||||||
06 seq.note letter='o' shift=0 length=1 netlength=1
|
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||||
`;
|
`;
|
||||||
const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi'];
|
const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi'];
|
||||||
ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true);
|
ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true);
|
||||||
@@ -567,12 +656,12 @@ const PS_FIXTURE = `00 bar '001P1L1M1'
|
|||||||
03 line.stem_note pitch='C4'
|
03 line.stem_note pitch='C4'
|
||||||
04 stem_note.chain _tmp_string='o.oe'
|
04 stem_note.chain _tmp_string='o.oe'
|
||||||
05 chain.clause 0
|
05 chain.clause 0
|
||||||
06 seq.note letter='o' shift=0 length=1 netlength=1
|
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||||
06 seq.pause length=1
|
06 seq.pause length=1
|
||||||
05 chain.clause 1
|
05 chain.clause 1
|
||||||
06 seq.stack length=2 netlength=2
|
06 seq.stack length=2 netlength=2
|
||||||
07 stack.note letter='o' shift=0
|
07 stack.note letters='o' shift=0
|
||||||
07 stack.note letter='e' shift=0
|
07 stack.note letters='e' shift=0
|
||||||
`;
|
`;
|
||||||
const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi'];
|
const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi'];
|
||||||
const psSn = psVoice.offsets[0].stemNotes[0];
|
const psSn = psVoice.offsets[0].stemNotes[0];
|
||||||
@@ -588,11 +677,11 @@ const NC_FIXTURE = `00 bar '001P1L1M1'
|
|||||||
03 line.stem_note pitch='C4'
|
03 line.stem_note pitch='C4'
|
||||||
04 stem_note.chain _tmp_string='o(...)'
|
04 stem_note.chain _tmp_string='o(...)'
|
||||||
05 chain.clause 0
|
05 chain.clause 0
|
||||||
06 seq.note letter='o' shift=0 length=1 netlength=1
|
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||||
06 seq.chain length=2
|
06 seq.chain length=2
|
||||||
07 chain.clause 0
|
07 chain.clause 0
|
||||||
08 seq.stack length=1 netlength=1
|
08 seq.stack length=1 netlength=1
|
||||||
09 stack.note letter='o' shift=0
|
09 stack.note letters='o' shift=0
|
||||||
`;
|
`;
|
||||||
const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||||
ok('nested chain: only 1 top-level clause', ncSn.clauses.length === 1);
|
ok('nested chain: only 1 top-level clause', ncSn.clauses.length === 1);
|
||||||
@@ -625,17 +714,17 @@ const LM_FIXTURE = `00 bar '001P1L1M1'
|
|||||||
03 line.stem_note pitch='C4'
|
03 line.stem_note pitch='C4'
|
||||||
04 stem_note.chain _tmp_string='o'
|
04 stem_note.chain _tmp_string='o'
|
||||||
05 chain.clause 0
|
05 chain.clause 0
|
||||||
06 seq.note letter='o' shift=0 length=1 netlength=1
|
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||||
03 line.motif 'coct'
|
03 line.motif 'coct'
|
||||||
03 line.motif 'oct' chord='C2'
|
03 line.motif 'oct' chord='C2'
|
||||||
`;
|
`;
|
||||||
const lmOffset = buildModel(parseAstLog(LM_FIXTURE)).bars[0].voices['pi'].offsets[0];
|
const lmOffset = buildModel(parseAstLog(LM_FIXTURE)).bars[0].voices['pi'].offsets[0];
|
||||||
ok('line.motif: offset has 1 stem note', lmOffset.stemNotes.length === 1);
|
ok('line.motif: offset has 1 stem note', lmOffset.stemNotes.length === 1);
|
||||||
ok('line.motif: offset.motifs has 2 entries', lmOffset.motifs.length === 2);
|
ok('line.motif: offset.motifRefs has 2 entries', lmOffset.motifRefs.length === 2);
|
||||||
ok('line.motif: first motif label', lmOffset.motifs[0].label === 'coct');
|
ok('line.motif: first motifRef label', lmOffset.motifRefs[0].label === 'coct');
|
||||||
ok('line.motif: second motif label', lmOffset.motifs[1].label === 'oct');
|
ok('line.motif: second motifRef label', lmOffset.motifRefs[1].label === 'oct');
|
||||||
ok('line.motif: second motif chord', lmOffset.motifs[1].chord === 'C2');
|
ok('line.motif: second motifRef chord', lmOffset.motifRefs[1].chord === 'C2');
|
||||||
ok('line.motif: first motif chord null', lmOffset.motifs[0].chord === null);
|
ok('line.motif: first motifRef chord null', lmOffset.motifRefs[0].chord === null);
|
||||||
|
|
||||||
section('stem_note.write_to and chainText');
|
section('stem_note.write_to and chainText');
|
||||||
const WT_FIXTURE = `00 bar '001P1L1M1'
|
const WT_FIXTURE = `00 bar '001P1L1M1'
|
||||||
@@ -645,8 +734,8 @@ const WT_FIXTURE = `00 bar '001P1L1M1'
|
|||||||
04 stem_note.write_to 'coct'
|
04 stem_note.write_to 'coct'
|
||||||
04 stem_note.chain _tmp_string='o=o+3*4'
|
04 stem_note.chain _tmp_string='o=o+3*4'
|
||||||
05 chain.clause 0 repeat=3
|
05 chain.clause 0 repeat=3
|
||||||
06 seq.note letter='o' shift=0 netlength=1 length=1
|
06 seq.note letters='o' shift=0 netlength=1 length=1
|
||||||
06 seq.note letter='o' shift=3 netlength=1 length=1
|
06 seq.note letters='o:f' shift=3 netlength=1 length=1
|
||||||
`;
|
`;
|
||||||
const wtSn = buildModel(parseAstLog(WT_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
const wtSn = buildModel(parseAstLog(WT_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||||
ok('write_to: writeToName', wtSn.writeToName === 'coct');
|
ok('write_to: writeToName', wtSn.writeToName === 'coct');
|
||||||
@@ -654,6 +743,8 @@ ok('write_to: chainText from _tmp_string', wtSn.chainText === 'o=o+3*4');
|
|||||||
ok('write_to: clause parsed', wtSn.clauses.length === 1);
|
ok('write_to: clause parsed', wtSn.clauses.length === 1);
|
||||||
ok('write_to: clause repeat', wtSn.clauses[0].repeat === 3);
|
ok('write_to: clause repeat', wtSn.clauses[0].repeat === 3);
|
||||||
ok('write_to: clause notes', wtSn.clauses[0].notes.length === 2);
|
ok('write_to: clause notes', wtSn.clauses[0].notes.length === 2);
|
||||||
|
ok('seq.note has letters field (not letter)', wtSn.clauses[0].notes[0].letters === 'o');
|
||||||
|
ok('seq.note letters with article ref', wtSn.clauses[0].notes[1].letters === 'o:f');
|
||||||
|
|
||||||
section('adjacent prop on stem_note');
|
section('adjacent prop on stem_note');
|
||||||
const ADJ_FIXTURE = `00 bar '001P1L1M1'
|
const ADJ_FIXTURE = `00 bar '001P1L1M1'
|
||||||
@@ -704,8 +795,8 @@ const voicesWithMotifs = model.bars.flatMap(b => Object.values(b.voices)).filter
|
|||||||
ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m)));
|
ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m)));
|
||||||
const offsetsWithStemNotes = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.stemNotes.length > 0);
|
const offsetsWithStemNotes = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.stemNotes.length > 0);
|
||||||
ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn)));
|
ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn)));
|
||||||
const offsetsWithMotifInvocations = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.motifs?.length > 0);
|
const offsetsWithMotifInvocations = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.motifRefs?.length > 0);
|
||||||
ok('fixture offset motif invocations are objects', offsetsWithMotifInvocations.every(o => o.motifs.every(m => 'label' in m)));
|
ok('fixture offset motif invocations are objects', offsetsWithMotifInvocations.every(o => o.motifRefs.every(m => 'label' in m)));
|
||||||
const stemNotesWithChainText = offsetsWithStemNotes.flatMap(o => o.stemNotes).filter(sn => sn.chainText);
|
const stemNotesWithChainText = offsetsWithStemNotes.flatMap(o => o.stemNotes).filter(sn => sn.chainText);
|
||||||
ok('fixture stem notes with chain text have clauses', stemNotesWithChainText.every(sn => sn.clauses.length > 0));
|
ok('fixture stem notes with chain text have clauses', stemNotesWithChainText.every(sn => sn.clauses.length > 0));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user