Compare commits
9 Commits
822e2c9f42
...
c52b0bf9cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52b0bf9cf | ||
|
|
4c392927cf | ||
|
|
2060f109b6 | ||
|
|
06b2bab5d3 | ||
|
|
839ea3f95c | ||
|
|
4afabea837 | ||
|
|
f318b155cc | ||
|
|
17f4529658 | ||
|
|
c8b8d03e3f |
@@ -178,7 +178,6 @@ function buildInstrument(node) {
|
||||
isDirty: false,
|
||||
variations: [],
|
||||
basicProperties: null,
|
||||
railsbackCurve: null,
|
||||
volumes: null,
|
||||
timbre: null,
|
||||
fmModulations: [],
|
||||
@@ -193,9 +192,6 @@ function buildInstrument(node) {
|
||||
case 'character.basic_properties':
|
||||
instr.basicProperties = buildBasicProperties(child);
|
||||
break;
|
||||
case 'RAILSBACK_CURVE.shape':
|
||||
instr.railsbackCurve = buildShape(child);
|
||||
break;
|
||||
case 'VOLUMES.shape':
|
||||
instr.volumes = buildShape(child);
|
||||
break;
|
||||
@@ -216,11 +212,12 @@ function buildInstrument(node) {
|
||||
function buildVariation(node) {
|
||||
const v = {
|
||||
type: 'variation',
|
||||
dependsOn: node.props.depends_on ?? null,
|
||||
dependsOn: node.props.depends_on ?? node.props.for_value ?? null,
|
||||
basicProperties: null,
|
||||
labelSpecs: [],
|
||||
subvariations: [],
|
||||
spread: null,
|
||||
railsbackCurve: null,
|
||||
rawChildren: [],
|
||||
};
|
||||
|
||||
@@ -239,6 +236,9 @@ function buildVariation(node) {
|
||||
case 'variation.SPREAD':
|
||||
v.spread = child.positionals;
|
||||
break;
|
||||
case 'RAILSBACK_CURVE.shape':
|
||||
v.railsbackCurve = buildShape(child);
|
||||
break;
|
||||
default:
|
||||
v.rawChildren.push(buildGeneric(child));
|
||||
}
|
||||
@@ -267,7 +267,10 @@ function buildBasicProperties(node) {
|
||||
} else if (child.parentSlot === 'variation' && child.slot === 'O') {
|
||||
bp.oscillator = child.props.ref ?? child.positionals[0];
|
||||
} else if (child.parentSlot === 'FM' && child.slot === 'modulation') {
|
||||
bp.fmModulations.push({ ...child.props });
|
||||
const fm = { ...child.props };
|
||||
const envChild = child.children.find(c => c.slot === 'shape');
|
||||
if (envChild) fm.shape = buildShape(envChild);
|
||||
bp.fmModulations.push(fm);
|
||||
} else {
|
||||
bp.rawChildren.push(buildGeneric(child));
|
||||
}
|
||||
@@ -284,13 +287,23 @@ function buildLabelSpec(node) {
|
||||
rawChildren: [],
|
||||
};
|
||||
|
||||
const directBpChildren = [];
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'variation' && child.slot === 'basic_properties') {
|
||||
ls.basicProperties = buildBasicProperties(child);
|
||||
} else if (
|
||||
(child.slot === 'shape' && (child.parentSlot === 'A' || child.parentSlot === 'S' || child.parentSlot === 'R')) ||
|
||||
(child.parentSlot === 'variation' && child.slot === 'O') ||
|
||||
(child.parentSlot === 'FM' && child.slot === 'modulation')
|
||||
) {
|
||||
directBpChildren.push(child);
|
||||
} else {
|
||||
ls.rawChildren.push(buildGeneric(child));
|
||||
}
|
||||
}
|
||||
if (!ls.basicProperties && directBpChildren.length > 0) {
|
||||
ls.basicProperties = buildBasicProperties({ children: directBpChildren });
|
||||
}
|
||||
|
||||
return ls;
|
||||
}
|
||||
@@ -300,7 +313,6 @@ function buildShape(node) {
|
||||
type: 'shape',
|
||||
length: node.props.length,
|
||||
start: node.props.start,
|
||||
z: node.props.z ?? 1,
|
||||
coords: node.children
|
||||
.filter(c => c.slot === 'coords')
|
||||
.map(c => ({
|
||||
@@ -313,15 +325,9 @@ function buildShape(node) {
|
||||
}
|
||||
|
||||
function buildBar(node) {
|
||||
const id = node.positionals[0] ?? '';
|
||||
const idMatch = id.match(/^(\w?)(\d+)P(\d+)L(\d+)M(\d+)$/);
|
||||
const bar = {
|
||||
type: 'bar',
|
||||
id,
|
||||
movement: idMatch ? idMatch[1] : '',
|
||||
part: idMatch ? parseInt(idMatch[2]) : 0,
|
||||
line: idMatch ? parseInt(idMatch[3]) : 0,
|
||||
measure: idMatch ? parseInt(idMatch[4]) : 0,
|
||||
id: node.positionals[0] ?? '',
|
||||
stressor: null,
|
||||
tempoShape: null,
|
||||
tempoLevels: null,
|
||||
|
||||
@@ -31,11 +31,11 @@ export const AppShell = {
|
||||
// Pane area
|
||||
h('div', { class: 'se-pane-area' }, [
|
||||
h('div', { class: ['se-pane', activePane.value === 'cp' ? 'active' : null] },
|
||||
h(PaneCP, { store, onImportClick: openImport })),
|
||||
h(PaneCP, { store, onImportClick: openImport, onFocusFO: () => { activePane.value = 'fo'; } })),
|
||||
h('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] },
|
||||
h(PaneFO, { store })),
|
||||
h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] },
|
||||
h(PaneSubObjects, { store })),
|
||||
h(PaneSubObjects, { store, onFocusFO: () => { activePane.value = 'fo'; } })),
|
||||
]),
|
||||
|
||||
// Handle bar (tab switcher at bottom)
|
||||
|
||||
@@ -24,12 +24,9 @@ export const EnvelopeEditor = {
|
||||
setup(props) {
|
||||
function toggle(section) {
|
||||
const bp = props.basicProperties;
|
||||
if (bp[section]) {
|
||||
bp[section] = null;
|
||||
} else {
|
||||
bp[section] = defaultShape(section);
|
||||
}
|
||||
props.onChange?.();
|
||||
const old = bp[section];
|
||||
bp[section] = old ? null : defaultShape(section);
|
||||
props.onChange?.({ undo: () => { bp[section] = old; } });
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -57,7 +54,7 @@ export const EnvelopeEditor = {
|
||||
? h('div', { class: disabled ? 'se-envelope-disabled' : null }, [
|
||||
h(ShapeEditor, {
|
||||
shape: bp[key],
|
||||
onChange: props.onChange,
|
||||
onChange: info => props.onChange?.(info),
|
||||
}),
|
||||
])
|
||||
: null,
|
||||
|
||||
@@ -3,22 +3,40 @@ import { fetchScoreText, putScoreText } from '../api.js';
|
||||
import { patchScore } from '../exporter.js';
|
||||
import { StatusPoller } from './StatusPoller.js';
|
||||
|
||||
// Short label + identifying meta for each node type.
|
||||
function shortView(node) {
|
||||
if (!node) return { typeTag: '?', label: '?', meta: [] };
|
||||
switch (node.type) {
|
||||
case 'score':
|
||||
return {
|
||||
typeTag: 'score',
|
||||
label: node.info?.title ?? '(untitled)',
|
||||
meta: node.info?.composer ? [{ key: 'composer', value: node.info.composer }] : [],
|
||||
};
|
||||
case 'instrument':
|
||||
return { typeTag: 'instrument', label: node.name, meta: [] };
|
||||
case 'variation': {
|
||||
const dep = node.dependsOn;
|
||||
const label = dep == null ? '(root variation)'
|
||||
: isNaN(Number(dep)) ? `ATTR: ${dep}`
|
||||
: String(dep);
|
||||
return { typeTag: 'variation', label, meta: [] };
|
||||
}
|
||||
case 'label_spec':
|
||||
return { typeTag: 'label', label: node.label ?? '(no label)', meta: [] };
|
||||
case 'bar':
|
||||
return { typeTag: 'bar', label: node.id, meta: [] };
|
||||
default:
|
||||
return { typeTag: node.type, label: node.type, meta: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export const PaneCP = {
|
||||
props: ['store', 'onImportClick'],
|
||||
props: ['store', 'onImportClick', 'onFocusFO'],
|
||||
setup(props) {
|
||||
const exporting = ref(false);
|
||||
const exportError = ref('');
|
||||
|
||||
function breadcrumbLabel(node) {
|
||||
if (!node) return '?';
|
||||
if (node.type === 'score') return 'Score';
|
||||
if (node.type === 'instrument') return node.name;
|
||||
if (node.type === 'variation') return node.dependsOn ? `var(${node.dependsOn})` : 'variation';
|
||||
if (node.type === 'label_spec') return node.label ?? 'label';
|
||||
if (node.type === 'bar') return node.id;
|
||||
return node.type;
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
exportError.value = '';
|
||||
exporting.value = true;
|
||||
@@ -27,7 +45,6 @@ export const PaneCP = {
|
||||
props.store.rawScoreText = raw;
|
||||
const patched = patchScore(raw, props.store.scoreModel.instruments);
|
||||
await putScoreText(patched, props.store.credentials);
|
||||
// Start polling
|
||||
props.store.synthesisStatus = { frozen: false, progress: 0 };
|
||||
} catch (e) {
|
||||
exportError.value = e.message;
|
||||
@@ -41,57 +58,66 @@ export const PaneCP = {
|
||||
const model = store.scoreModel;
|
||||
const fp = store.focusPath;
|
||||
|
||||
return h('div', { class: 'se-pane' }, [
|
||||
// Build vertical path list: score root + each focus node.
|
||||
const pathItems = model ? [
|
||||
{ node: model, idx: -1 },
|
||||
...fp.map((node, idx) => ({ node, idx })),
|
||||
] : [];
|
||||
|
||||
return h('div', null, [
|
||||
// Header
|
||||
h('div', { class: 'se-cp-header' }, [
|
||||
h('span', { class: 'se-cp-title' },
|
||||
model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'),
|
||||
h('button', {
|
||||
class: 'se-btn',
|
||||
disabled: store.isDirty,
|
||||
title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server',
|
||||
onClick: props.onImportClick,
|
||||
}, 'Import'),
|
||||
}, '→ Import'),
|
||||
h('span', { class: 'se-cp-title' },
|
||||
model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'),
|
||||
model ? h('button', {
|
||||
class: 'se-btn se-btn-primary',
|
||||
disabled: !store.isDirty || exporting.value,
|
||||
onClick: doExport,
|
||||
}, exporting.value ? 'Exporting…' : 'Export') : null,
|
||||
}, exporting.value ? 'Exporting…' : 'Export ↑') : null,
|
||||
]),
|
||||
|
||||
// Breadcrumb
|
||||
fp.length ? h('div', { class: 'se-breadcrumb' }, [
|
||||
h('span', { onClick: () => store.setFocus([]) }, 'Score'),
|
||||
...fp.map((node, i) => [
|
||||
' › ',
|
||||
h('span', { onClick: () => store.setFocus(fp.slice(0, i + 1)) },
|
||||
breadcrumbLabel(node)),
|
||||
]).flat(),
|
||||
]) : null,
|
||||
|
||||
// Score info
|
||||
model ? h('dl', { style: 'font-size:0.8rem;margin:0.5rem 0' }, [
|
||||
h('dt', null, 'Instruments'),
|
||||
h('dd', null, String(model.instruments.length)),
|
||||
h('dt', null, 'Bars'),
|
||||
h('dd', null, String(model.bars.length)),
|
||||
]) : null,
|
||||
// Vertical focus path — short views, clickable to navigate up
|
||||
pathItems.length ? h('ul', { class: 'se-object-list se-cp-path' },
|
||||
pathItems.map(({ node, idx }) => {
|
||||
const { typeTag, label, meta } = shortView(node);
|
||||
const isCurrent = idx === fp.length - 1 || (idx === -1 && fp.length === 0);
|
||||
return h('li', {
|
||||
class: ['se-object-item', isCurrent ? 'focused' : null],
|
||||
onClick: () => {
|
||||
if (idx === -1) store.setFocus([]);
|
||||
else store.setFocus(fp.slice(0, idx + 1));
|
||||
props.onFocusFO?.();
|
||||
},
|
||||
}, [
|
||||
h('span', { class: 'se-object-type' }, typeTag),
|
||||
h('span', { class: 'se-object-label' }, [
|
||||
h('strong', null, label),
|
||||
...meta.map(({ key, value }) =>
|
||||
h('span', { style: 'color:#888;margin-left:0.5rem;font-size:0.8em' },
|
||||
`${key}=${value}`)
|
||||
),
|
||||
]),
|
||||
]);
|
||||
})
|
||||
) : null,
|
||||
|
||||
// Export error
|
||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
||||
|
||||
// Status poller (shown after export started)
|
||||
// Status poller
|
||||
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||
? h(StatusPoller, { store })
|
||||
: null,
|
||||
? h(StatusPoller, { store }) : null,
|
||||
|
||||
// Result link
|
||||
store.synthesisStatus?.frozen && !store.synthesisStatus?.error
|
||||
? h('a', {
|
||||
href: '/sompyle/result.mp3',
|
||||
style: 'display:block;margin-top:0.5rem',
|
||||
}, 'Download result')
|
||||
: null,
|
||||
? h('a', { href: '/sompyle/result.mp3', style: 'display:block;margin-top:0.5rem' },
|
||||
'Download result') : null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3,6 +3,15 @@ import { ObjectExtended } from './ObjectExtended.js';
|
||||
import { EnvelopeEditor } from './EnvelopeEditor.js';
|
||||
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
|
||||
|
||||
function scoreInfoFields(info) {
|
||||
return [
|
||||
{ key: 'title', value: info?.title ?? '', editable: true },
|
||||
{ key: 'composer', value: info?.composer ?? '', editable: true },
|
||||
{ key: 'source', value: info?.source ?? '', editable: true },
|
||||
{ key: 'encrypter', value: info?.encrypter ?? '', editable: true },
|
||||
];
|
||||
}
|
||||
|
||||
function instrFields(instr) {
|
||||
return [
|
||||
{ key: 'name', value: instr.name, editable: false },
|
||||
@@ -30,11 +39,11 @@ export const PaneFO = {
|
||||
|
||||
// Returns a change handler that intercepts the first edit to a linked
|
||||
// instrument and shows the embed-or-discard modal before applying it.
|
||||
// `info` may carry { undo } forwarded from ShapeEditor / EnvelopeEditor.
|
||||
function makeChangeHandler(instr, apply) {
|
||||
return () => {
|
||||
return (info) => {
|
||||
if (instr.isLinked && !instr.isDirty) {
|
||||
// Stash the apply callback and show the modal.
|
||||
pendingEdit.value = { instr, apply };
|
||||
pendingEdit.value = { instr, apply, undo: info?.undo };
|
||||
} else {
|
||||
apply();
|
||||
instr.isDirty = true;
|
||||
@@ -53,6 +62,7 @@ export const PaneFO = {
|
||||
}
|
||||
|
||||
function discardEdit() {
|
||||
pendingEdit.value?.undo?.();
|
||||
pendingEdit.value = null;
|
||||
}
|
||||
|
||||
@@ -61,7 +71,19 @@ export const PaneFO = {
|
||||
const children = [];
|
||||
|
||||
if (!node || node.type === 'score') {
|
||||
return h('div', { class: 'se-fo-pane' }, 'Nothing selected');
|
||||
const model = props.store.scoreModel;
|
||||
if (!model) return h('div', { class: 'se-fo-pane' }, 'No score loaded');
|
||||
return h('div', { class: 'se-fo-pane' }, [
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, 'Score'),
|
||||
h(ObjectExtended, {
|
||||
fields: scoreInfoFields(model.info),
|
||||
onChange: ({ key, value }) => {
|
||||
if (!model.info) model.info = {};
|
||||
model.info[key] = value;
|
||||
props.store.markDirty();
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
if (node.type === 'instrument') {
|
||||
@@ -83,8 +105,13 @@ export const PaneFO = {
|
||||
children.push(
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, 'Variation'),
|
||||
h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
|
||||
if (key === 'depends_on') node.dependsOn = value;
|
||||
onChange();
|
||||
if (key === 'depends_on') {
|
||||
const old = node.dependsOn;
|
||||
node.dependsOn = value;
|
||||
onChange({ undo: () => { node.dependsOn = old; } });
|
||||
} else {
|
||||
onChange({});
|
||||
}
|
||||
}}),
|
||||
node.basicProperties
|
||||
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
|
||||
@@ -112,10 +139,7 @@ export const PaneFO = {
|
||||
children.push(
|
||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Bar: ${node.id}`),
|
||||
h(ObjectExtended, { fields: [
|
||||
{ key: 'movement', value: node.movement },
|
||||
{ key: 'part', value: node.part },
|
||||
{ key: 'line', value: node.line },
|
||||
{ key: 'measure', value: node.measure },
|
||||
{ key: 'id', value: node.id, editable: false },
|
||||
], onChange: null }),
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ObjectShort } from './ObjectShort.js';
|
||||
|
||||
// Shows sub-objects of the currently focused node — variations, bars, voices, etc.
|
||||
export const PaneSubObjects = {
|
||||
props: ['store'],
|
||||
props: ['store', 'onFocusFO'],
|
||||
setup(props) {
|
||||
function focused() {
|
||||
const fp = props.store.focusPath;
|
||||
@@ -13,10 +13,20 @@ export const PaneSubObjects = {
|
||||
function subItems(node) {
|
||||
if (!node) return [];
|
||||
if (node.type === 'score') {
|
||||
return [
|
||||
...node.instruments.map(i => ({ kind: 'instrument', node: i, label: i.name })),
|
||||
...node.bars.map(b => ({ kind: 'bar', node: b, label: b.id })),
|
||||
];
|
||||
const items = [];
|
||||
if (node.info)
|
||||
items.push({ kind: 'info', node: node.info, label: node.info.title ?? '(no title)', hasChildren: false });
|
||||
if (node.tuning)
|
||||
items.push({ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false });
|
||||
for (const a of (node.articles ?? []))
|
||||
items.push({ kind: 'article', node: a, label: a.name, hasChildren: false });
|
||||
for (const sv of (node.stageVoices ?? []))
|
||||
items.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false });
|
||||
for (const i of node.instruments)
|
||||
items.push({ kind: 'instrument', node: i, label: i.name, hasChildren: true });
|
||||
for (const b of node.bars)
|
||||
items.push({ kind: 'bar', node: b, label: b.id, hasChildren: false });
|
||||
return items;
|
||||
}
|
||||
if (node.type === 'instrument') {
|
||||
return node.variations.map((v, idx) => ({
|
||||
@@ -30,9 +40,13 @@ export const PaneSubObjects = {
|
||||
...node.labelSpecs.map(ls => ({
|
||||
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)',
|
||||
})),
|
||||
...node.subvariations.map((sv, idx) => ({
|
||||
kind: 'variation', node: sv, label: `subvariation ${idx + 1}`,
|
||||
})),
|
||||
...node.subvariations.map((sv, idx) => {
|
||||
const dep = sv.dependsOn;
|
||||
const label = dep == null ? `subvariation ${idx + 1}`
|
||||
: isNaN(Number(dep)) ? `ATTR: ${dep}`
|
||||
: String(dep);
|
||||
return { kind: 'variation', node: sv, label, hasChildren: true };
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (node.type === 'bar') {
|
||||
@@ -46,9 +60,9 @@ export const PaneSubObjects = {
|
||||
return () => {
|
||||
const node = focused();
|
||||
const items = subItems(node);
|
||||
if (!items.length) return h('div', { class: 'se-pane' }, h('em', null, 'No sub-objects'));
|
||||
if (!items.length) return h('div', null, h('em', null, 'No sub-objects'));
|
||||
|
||||
return h('div', { class: 'se-pane' }, [
|
||||
return h('div', null, [
|
||||
h('ul', { class: 'se-object-list' }, items.map((item, idx) =>
|
||||
h(ObjectShort, {
|
||||
key: idx,
|
||||
@@ -56,9 +70,9 @@ export const PaneSubObjects = {
|
||||
label: item.label,
|
||||
typeTag: item.kind,
|
||||
focused: props.store.focusPath.includes(item.node),
|
||||
hasChildren: item.kind !== 'bar' && item.kind !== 'voice',
|
||||
onFocus: () => props.store.pushFocus(item.node),
|
||||
onDrillDown: () => props.store.pushFocus(item.node),
|
||||
hasChildren: item.hasChildren ?? (item.kind !== 'bar' && item.kind !== 'voice'),
|
||||
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||
onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||
})
|
||||
)),
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Renders a shape's coord table with cascade-shift and an SVG preview.
|
||||
// `shape` is mutated in place; `onChange` called after each mutation.
|
||||
// `shape` is mutated in place; `onChange` called after each mutation with
|
||||
// { undo } so callers can revert if needed.
|
||||
|
||||
export const ShapeEditor = {
|
||||
props: ['shape', 'onChange'],
|
||||
@@ -14,27 +15,31 @@ export const ShapeEditor = {
|
||||
const delta = num - old;
|
||||
coord[field] = num;
|
||||
|
||||
// cascade-shift: if x increased past next coord, shift all following
|
||||
const shifted = [];
|
||||
if (field === 'x' && delta > 0) {
|
||||
for (let j = i + 1; j < props.shape.coords.length; j++) {
|
||||
if (props.shape.coords[j].x <= num) {
|
||||
shifted.push({ j, ox: props.shape.coords[j].x });
|
||||
props.shape.coords[j].x += delta;
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
props.onChange?.();
|
||||
props.onChange?.({ undo: () => {
|
||||
coord[field] = old;
|
||||
for (const { j, ox } of shifted) props.shape.coords[j].x = ox;
|
||||
}});
|
||||
}
|
||||
|
||||
function addCoord() {
|
||||
const coords = props.shape.coords;
|
||||
const lastX = coords.length ? coords[coords.length - 1].x + 1 : 1;
|
||||
coords.push({ x: lastX, y: 0, z: 1, isSharp: false });
|
||||
props.onChange?.();
|
||||
props.onChange?.({ undo: () => coords.pop() });
|
||||
}
|
||||
|
||||
function removeCoord(i) {
|
||||
props.shape.coords.splice(i, 1);
|
||||
props.onChange?.();
|
||||
const removed = props.shape.coords.splice(i, 1)[0];
|
||||
props.onChange?.({ undo: () => props.shape.coords.splice(i, 0, removed) });
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
@@ -55,19 +60,14 @@ export const ShapeEditor = {
|
||||
return [sx, sy];
|
||||
};
|
||||
|
||||
const points = coords.map(c => toSvg(c).join(',') ).join(' ');
|
||||
const points = coords.map(c => toSvg(c).join(',')).join(' ');
|
||||
|
||||
return h('svg', {
|
||||
class: 'se-shape-svg',
|
||||
viewBox: `0 0 ${W} ${H}`,
|
||||
preserveAspectRatio: 'none',
|
||||
}, [
|
||||
h('polyline', {
|
||||
points,
|
||||
fill: 'none',
|
||||
stroke: '#2a6aaa',
|
||||
'stroke-width': '1.5',
|
||||
}),
|
||||
h('polyline', { points, fill: 'none', stroke: '#2a6aaa', 'stroke-width': '1.5' }),
|
||||
...coords.map(c => {
|
||||
const [sx, sy] = toSvg(c);
|
||||
return h('circle', { cx: sx, cy: sy, r: 2.5, fill: '#6aacff' });
|
||||
@@ -101,7 +101,11 @@ export const ShapeEditor = {
|
||||
})),
|
||||
h('td', null, h('input', {
|
||||
type: 'checkbox', checked: !!coord.isSharp,
|
||||
onChange: e => { coord.isSharp = e.target.checked; props.onChange?.(); },
|
||||
onChange: e => {
|
||||
const old = coord.isSharp;
|
||||
coord.isSharp = e.target.checked;
|
||||
props.onChange?.({ undo: () => { coord.isSharp = old; } });
|
||||
},
|
||||
})),
|
||||
h('td', null, h('button', { onClick: () => removeCoord(i) }, '✕')),
|
||||
])
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
import { h, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { h, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { fetchStatus } from '../api.js';
|
||||
|
||||
export const StatusPoller = {
|
||||
props: ['store'],
|
||||
setup(props) {
|
||||
const timer = ref(null);
|
||||
const eta = ref(null);
|
||||
|
||||
function nextInterval(status) {
|
||||
if (!status) return 2000;
|
||||
const pct = status.progress ?? 0;
|
||||
// At 0–20%: poll at centile-of-ETA intervals
|
||||
if (eta.value && pct > 0 && pct <= 20) {
|
||||
return Math.max(500, (eta.value * 10) | 0);
|
||||
}
|
||||
return 2000;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const status = await fetchStatus(props.store.credentials);
|
||||
if (status.eta) eta.value = status.eta;
|
||||
props.store.synthesisStatus = status;
|
||||
if (status.frozen) {
|
||||
// done — stop polling
|
||||
return;
|
||||
}
|
||||
if (status.frozen) return;
|
||||
} catch (_) {
|
||||
// transient error — keep polling
|
||||
// transient — keep polling
|
||||
}
|
||||
timer.value = setTimeout(poll, nextInterval(props.store.synthesisStatus));
|
||||
timer.value = setTimeout(poll, 2000);
|
||||
}
|
||||
|
||||
onMounted(() => { poll(); });
|
||||
@@ -39,10 +24,16 @@ export const StatusPoller = {
|
||||
const s = props.store.synthesisStatus;
|
||||
if (!s) return h('div', { class: 'se-status' }, 'Polling…');
|
||||
|
||||
const pct = s.progress ?? 0;
|
||||
const label = s.frozen
|
||||
? (s.error ? `Error: ${s.error}` : 'Done')
|
||||
: `${s.state ?? 'Running'} ${pct}%`;
|
||||
const total = s.notes_in_total ?? 0;
|
||||
const done = s.currently_rendered_notes ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, Math.round(done / total * 100)) : 0;
|
||||
|
||||
let label;
|
||||
if (s.frozen) {
|
||||
label = s.errors ? `Error: ${s.errors}` : 'Done';
|
||||
} else {
|
||||
label = s.remaining_time ?? `Synthesizing… ${pct}%`;
|
||||
}
|
||||
|
||||
return h('div', { class: 'se-status' }, [
|
||||
h('span', null, label),
|
||||
|
||||
@@ -1,138 +1,160 @@
|
||||
// Template-based YAML serializer — instrument blocks only (v1).
|
||||
// Each object selects a template by finding the first entry in its
|
||||
// selectExportTemplate() list where all required slots have values.
|
||||
// Placeholders #0, #1, ... are filled with slot values.
|
||||
|
||||
function fillTemplate(template, slots) {
|
||||
return template.replace(/#(\d+)/g, (_, i) => slots[parseInt(i, 10)] ?? '');
|
||||
}
|
||||
|
||||
function indent(text, level) {
|
||||
const pad = ' '.repeat(level);
|
||||
return text.split('\n').map((line, i) => {
|
||||
if (i === 0) return line;
|
||||
if (line.startsWith('- ')) return pad.slice(2) + line;
|
||||
return pad + line;
|
||||
}).join('\n');
|
||||
}
|
||||
// RFC-compliant YAML serializer for Sompyler instrument blocks.
|
||||
// Operates on the model produced by ast-parser.js buildModel().
|
||||
|
||||
// ── Shape ──────────────────────────────────────────────────────────────────
|
||||
// RFC §1.3.4.5: SHAPE = [PREFIX (":" / ";")] Node 1*(";" Node)
|
||||
// Node = x "," y ["*" z] ["!"]
|
||||
// PREFIX+colon is the duration/resolution; optional START+semicolon follows.
|
||||
|
||||
function exportCoord(coord) {
|
||||
let s = `x=${coord.x} y=${coord.y}`;
|
||||
if (coord.z !== undefined && coord.z !== 1) s += ` z=${coord.z}`;
|
||||
if (coord.isSharp) s += ` is_sharp=True`;
|
||||
function serializeShape(shape) {
|
||||
if (!shape) return null;
|
||||
const nodes = shape.coords.map(c => {
|
||||
let s = `${c.x},${c.y}`;
|
||||
if (c.z !== undefined && c.z !== 1) s += `*${c.z}`;
|
||||
if (c.isSharp) s += '!';
|
||||
return s;
|
||||
}).join(';');
|
||||
let prefix = '';
|
||||
if (shape.length != null) prefix = `${shape.length}:`;
|
||||
if (shape.start != null) prefix += `${shape.start};`;
|
||||
return prefix + nodes;
|
||||
}
|
||||
|
||||
// ── FM / AM modulation ─────────────────────────────────────────────────────
|
||||
// RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
|
||||
|
||||
function serializeFm(fm) {
|
||||
let s = String(fm.frequency ?? '');
|
||||
if (fm.factor) s += fm.factor;
|
||||
if (fm.osc) s += `@${fm.osc}`;
|
||||
if (fm.shape) s += `[${serializeShape(fm.shape)}]`;
|
||||
s += `;${fm.mod ?? ''}:${fm.base ?? ''}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function exportShape(shape, slotName, level) {
|
||||
if (!shape) return '';
|
||||
const coordLines = shape.coords.map(c => ` - coords: ${exportCoord(c)}`).join('\n');
|
||||
let header = `${slotName}: length=${shape.length}`;
|
||||
if (shape.start !== undefined) header += ` start=${shape.start}`;
|
||||
if (shape.z !== undefined && shape.z !== 1) header += ` z=${shape.z}`;
|
||||
const block = coordLines ? `${header}\n${coordLines}` : header;
|
||||
return indent(block, level);
|
||||
}
|
||||
// ── Basic properties ───────────────────────────────────────────────────────
|
||||
// RFC §3.2.1.1: O, A, S, R, FM go directly in the variation MAPPING.
|
||||
// Returns array of YAML lines at 0 indent.
|
||||
|
||||
// ── BasicProperties ────────────────────────────────────────────────────────
|
||||
|
||||
function exportBasicProperties(bp, level) {
|
||||
if (!bp) return '';
|
||||
function basicPropLines(bp) {
|
||||
if (!bp) return [];
|
||||
const lines = [];
|
||||
if (bp.oscillator) lines.push(` O: ref=${bp.oscillator}`);
|
||||
if (bp.A) lines.push(` ${exportShape(bp.A, 'A', 1)}`);
|
||||
if (bp.S) lines.push(` ${exportShape(bp.S, 'S', 1)}`);
|
||||
if (bp.R) lines.push(` ${exportShape(bp.R, 'R', 1)}`);
|
||||
for (const fm of (bp.fmModulations ?? [])) {
|
||||
const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' ');
|
||||
lines.push(` FM:\n modulation: ${parts}`);
|
||||
}
|
||||
if (!lines.length) return '';
|
||||
return indent('basic_properties:\n' + lines.join('\n'), level);
|
||||
if (bp.oscillator) lines.push(`O: ${bp.oscillator}`);
|
||||
const a = serializeShape(bp.A);
|
||||
if (a) lines.push(`A: "${a}"`);
|
||||
const s = serializeShape(bp.S);
|
||||
if (s) lines.push(`S: "${s}"`);
|
||||
const r = serializeShape(bp.R);
|
||||
if (r) lines.push(`R: "${r}"`);
|
||||
for (const fm of (bp.fmModulations ?? [])) lines.push(`FM: "${serializeFm(fm)}"`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── LabelSpec ─────────────────────────────────────────────────────────────
|
||||
// ── Labelled property groups ───────────────────────────────────────────────
|
||||
// RFC §3.2.1.2: label name (3+ lowercase chars) is the MAPPING KEY directly.
|
||||
// Returns array of YAML lines at 0 indent.
|
||||
|
||||
function exportLabelSpec(ls, level) {
|
||||
const label = ls.label ? ` '${ls.label}'` : '';
|
||||
const bp = exportBasicProperties(ls.basicProperties, 1);
|
||||
const body = bp ? `label_spec:${label}\n ${bp}` : `label_spec:${label}`;
|
||||
return indent(body, level);
|
||||
function labelSpecLines(ls) {
|
||||
const inner = basicPropLines(ls.basicProperties);
|
||||
if (!inner.length) return [`${ls.label}:`];
|
||||
return [`${ls.label}:`, ...inner.map(l => ` ${l}`)];
|
||||
}
|
||||
|
||||
// ── Variation ─────────────────────────────────────────────────────────────
|
||||
// Returns YAML lines for one variation MAPPING (no leading "- ").
|
||||
// RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level.
|
||||
|
||||
function exportVariation(v, level) {
|
||||
const dep = v.dependsOn ? ` depends_on=${v.dependsOn}` : '';
|
||||
const lines = [`variation:${dep}`];
|
||||
if (v.basicProperties) lines.push(` ${exportBasicProperties(v.basicProperties, 1)}`);
|
||||
for (const ls of (v.labelSpecs ?? [])) lines.push(` ${exportLabelSpec(ls, 1)}`);
|
||||
for (const sv of (v.subvariations ?? [])) lines.push(` ${exportVariation(sv, 1)}`);
|
||||
if (v.spread?.length) lines.push(` SPREAD: ${v.spread.join(' ')}`);
|
||||
return indent(lines.join('\n'), level);
|
||||
function variationLines(v) {
|
||||
const lines = [];
|
||||
if (v.dependsOn) lines.push(`ATTR: ${v.dependsOn}`);
|
||||
lines.push(...basicPropLines(v.basicProperties));
|
||||
for (const ls of (v.labelSpecs ?? [])) lines.push(...labelSpecLines(ls));
|
||||
if (v.spread?.length) lines.push(`SPREAD: [${v.spread.join(', ')}]`);
|
||||
if (v.railsbackCurve) { const rc = serializeShape(v.railsbackCurve); if (rc) lines.push(`RAILSBACK_CURVE: "${rc}"`); }
|
||||
const vol = serializeShape(v.volumes);
|
||||
if (vol) lines.push(`VOLUMES: "${vol}"`);
|
||||
const timbre = serializeShape(v.timbre);
|
||||
if (timbre) lines.push(`TIMBRE: "${timbre}"`);
|
||||
for (const fm of (v.fmModulations ?? [])) lines.push(`FM: "${serializeFm(fm)}"`);
|
||||
for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Instrument character block ─────────────────────────────────────────────
|
||||
// VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser
|
||||
// stores them on the instrument because they appear at depth 01 (root variation
|
||||
// is implicit when no character: wrapper exists). Promote them into a synthetic
|
||||
// root variation here so the export structure is RFC-correct.
|
||||
|
||||
function instrCharacterLines(instr) {
|
||||
const variations = instr.variations ?? [];
|
||||
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
|
||||
(instr.fmModulations ?? []).length > 0;
|
||||
const syntheticRoot = hasRootProps
|
||||
? {
|
||||
basicProperties: instr.basicProperties,
|
||||
labelSpecs: [], subvariations: [], spread: null,
|
||||
dependsOn: null, railsbackCurve: null,
|
||||
volumes: instr.volumes,
|
||||
timbre: instr.timbre,
|
||||
fmModulations: instr.fmModulations ?? [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const allVariations = [
|
||||
...(syntheticRoot ? [syntheticRoot] : []),
|
||||
...variations,
|
||||
];
|
||||
|
||||
if (allVariations.length <= 1) {
|
||||
const vLines = allVariations.length ? variationLines(allVariations[0]) : [];
|
||||
return vLines.map(l => ` ${l}`);
|
||||
}
|
||||
|
||||
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
|
||||
const result = [];
|
||||
for (const v of allVariations) {
|
||||
const vLines = variationLines(v);
|
||||
if (!vLines.length) continue;
|
||||
result.push(` - ${vLines[0]}`);
|
||||
for (const l of vLines.slice(1)) result.push(` ${l}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Instrument ────────────────────────────────────────────────────────────
|
||||
// RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'"
|
||||
|
||||
export function exportInstrument(instr) {
|
||||
const lines = [];
|
||||
const name = instr.name;
|
||||
lines.push(`instrument: '${name}'`);
|
||||
|
||||
for (const v of (instr.variations ?? [])) {
|
||||
lines.push(` character:\n ${exportVariation(v, 2)}`);
|
||||
}
|
||||
|
||||
if (instr.basicProperties) {
|
||||
lines.push(` character:\n ${exportBasicProperties(instr.basicProperties, 2)}`);
|
||||
}
|
||||
|
||||
if (instr.railsbackCurve) {
|
||||
lines.push(` ${exportShape(instr.railsbackCurve, 'RAILSBACK_CURVE', 1)}`);
|
||||
}
|
||||
if (instr.volumes) {
|
||||
lines.push(` ${exportShape(instr.volumes, 'VOLUMES', 1)}`);
|
||||
}
|
||||
if (instr.timbre) {
|
||||
lines.push(` ${exportShape(instr.timbre, 'TIMBRE', 1)}`);
|
||||
}
|
||||
for (const fm of (instr.fmModulations ?? [])) {
|
||||
const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' ');
|
||||
lines.push(` FM:\n modulation: ${parts}`);
|
||||
}
|
||||
|
||||
const lines = [`instrument ${instr.name}:`];
|
||||
if (instr.notChangedSince) lines.push(` NOT_CHANGED_SINCE: ${instr.notChangedSince}`);
|
||||
lines.push(` character:`);
|
||||
lines.push(...instrCharacterLines(instr));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Score patch ────────────────────────────────────────────────────────────
|
||||
// Replace dirty instrument blocks in rawScoreText with RFC-serialized output.
|
||||
// Non-dirty instruments are left verbatim.
|
||||
|
||||
// Replace instrument blocks in rawScoreText with serialized model instruments.
|
||||
// Non-dirty linked instruments (NOT_CHANGED_SINCE set, not edited) are left as-is
|
||||
// from rawScoreText. Embedded and dirty instruments are emitted from the model.
|
||||
export function patchScore(rawScoreText, instruments) {
|
||||
// Split raw text into instrument blocks and other sections.
|
||||
// Strategy: locate each `^instrument:` line and replace that block
|
||||
// (up to next same-indent section or EOF) with the serialized model.
|
||||
|
||||
const lines = rawScoreText.split('\n');
|
||||
const result = [];
|
||||
const instrMap = {};
|
||||
for (const instr of instruments) {
|
||||
const basename = instr.name.includes('/') ? instr.name.split('/').pop() : instr.name;
|
||||
instrMap[basename] = instr;
|
||||
instrMap[instr.name] = instr;
|
||||
if (instr.name.includes('/')) instrMap[instr.name.split('/').pop()] = instr;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const m = line.match(/^instrument:\s+'?([^']+)'?/);
|
||||
// RFC §4.4: "instrument NAME:" — strip optional quotes around name
|
||||
const m = line.match(/^instrument\s+(.+?)\s*:/);
|
||||
if (m) {
|
||||
const rawName = m[1];
|
||||
const rawName = m[1].replace(/^'|'$/g, '');
|
||||
const instr = instrMap[rawName];
|
||||
if (instr && instr.isDirty) {
|
||||
// consume the raw block
|
||||
i++;
|
||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||
result.push(exportInstrument(instr));
|
||||
|
||||
249
test-parser.mjs
Normal file
249
test-parser.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
// Fixture-based compliance test for ast-parser.js + exporter.js
|
||||
// Run: node test-parser.mjs
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { parseAstLog, buildModel } from './static/ast-parser.js';
|
||||
import { exportInstrument, patchScore } from './static/exporter.js';
|
||||
|
||||
const FIXTURE = new URL('./fixtures/ast.log', import.meta.url);
|
||||
const text = readFileSync(FIXTURE, 'utf8');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(label, value) {
|
||||
if (value) { console.log(` ✓ ${label}`); pass++; }
|
||||
else { console.error(` ✗ ${label}`); fail++; }
|
||||
}
|
||||
function section(name) { console.log(`\n── ${name}`); }
|
||||
|
||||
// ── Parse ──────────────────────────────────────────────────────────────────
|
||||
section('Parse pass');
|
||||
const raw = parseAstLog(text);
|
||||
ok('root node exists', raw && raw.slot === 'root');
|
||||
ok('root has children', raw.children.length > 0);
|
||||
|
||||
// ── Build model ────────────────────────────────────────────────────────────
|
||||
section('Build model');
|
||||
const model = buildModel(raw);
|
||||
ok('score type', model.type === 'score');
|
||||
ok('has instruments', model.instruments.length > 0);
|
||||
ok('has bars', model.bars.length > 0);
|
||||
ok('432 bars', model.bars.length === 432);
|
||||
console.log(` bars: ${model.bars.length}, instruments: ${model.instruments.length}`);
|
||||
|
||||
// ── Bar IDs ────────────────────────────────────────────────────────────────
|
||||
// Bar IDs are opaque auto-increment strings; only the raw id string matters.
|
||||
section('Bar IDs');
|
||||
const emptyIdBars = model.bars.filter(b => !b.id);
|
||||
ok('all bars have non-empty id', emptyIdBars.length === 0);
|
||||
if (emptyIdBars.length) console.error(` ${emptyIdBars.length} bars have no id`);
|
||||
|
||||
// ── Instruments ────────────────────────────────────────────────────────────
|
||||
section('Instruments');
|
||||
for (const instr of model.instruments) {
|
||||
const label = `instrument "${instr.name}"`;
|
||||
ok(`${label} has name`, typeof instr.name === 'string' && instr.name.length > 0);
|
||||
ok(`${label} has variations or basicProperties`,
|
||||
instr.variations.length > 0 || instr.basicProperties !== null);
|
||||
|
||||
for (const v of instr.variations) {
|
||||
ok(`${label} variation type`, v.type === 'variation');
|
||||
if (v.basicProperties) {
|
||||
const bp = v.basicProperties;
|
||||
for (const key of ['A', 'S', 'R']) {
|
||||
if (bp[key]) {
|
||||
ok(`${label} ${key} shape has coords array`, Array.isArray(bp[key].coords));
|
||||
for (const c of bp[key].coords) {
|
||||
ok(`${label} ${key} coord has x+y`, c.x !== undefined && c.y !== undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape roundtrip ────────────────────────────────────────────────────────
|
||||
section('Shape roundtrip (parse → export string)');
|
||||
|
||||
// Verify serializeShape output matches RFC pattern:
|
||||
// [length:][start;]x,y[*z][!] separated by ;
|
||||
const SHAPE_RE = /^(\d+(\.\d+)?:)?(\d+(\.\d+)?;)?(-?\d+(\.\d+)?,-?\d+(\.\d+)?(\*-?\d+(\.\d+)?)?!?(;-?\d+(\.\d+)?,-?\d+(\.\d+)?(\*-?\d+(\.\d+)?)?!?)*)$/;
|
||||
|
||||
function serializeShape(shape) {
|
||||
if (!shape) return null;
|
||||
const nodes = shape.coords.map(c => {
|
||||
let s = `${c.x},${c.y}`;
|
||||
if (c.z !== undefined && c.z !== 1) s += `*${c.z}`;
|
||||
if (c.isSharp) s += '!';
|
||||
return s;
|
||||
}).join(';');
|
||||
let prefix = '';
|
||||
if (shape.length != null) prefix = `${shape.length}:`;
|
||||
if (shape.start != null) prefix += `${shape.start};`;
|
||||
return prefix + nodes;
|
||||
}
|
||||
|
||||
let shapesChecked = 0;
|
||||
for (const instr of model.instruments) {
|
||||
for (const v of instr.variations) {
|
||||
if (!v.basicProperties) continue;
|
||||
for (const key of ['A', 'S', 'R']) {
|
||||
const s = v.basicProperties[key];
|
||||
if (!s) continue;
|
||||
const str = serializeShape(s);
|
||||
ok(`${instr.name} ${key} shape serializes`, str !== null && str.length > 0);
|
||||
ok(`${instr.name} ${key} shape matches RFC pattern`, SHAPE_RE.test(str));
|
||||
shapesChecked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` shapes checked: ${shapesChecked}`);
|
||||
|
||||
// ── Exporter ───────────────────────────────────────────────────────────────
|
||||
section('exportInstrument output');
|
||||
|
||||
// RFC §4.4: instrument block must start with "instrument NAME:"
|
||||
// followed by " character:" block
|
||||
for (const instr of model.instruments) {
|
||||
instr.isDirty = true; // force export path
|
||||
let out;
|
||||
try {
|
||||
out = exportInstrument(instr);
|
||||
} catch (e) {
|
||||
ok(`${instr.name} exportInstrument throws`, false);
|
||||
console.error(` ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
const lines = out.split('\n');
|
||||
ok(`${instr.name} starts with "instrument NAME:"`,
|
||||
/^instrument \S+.*:/.test(lines[0]));
|
||||
ok(`${instr.name} has character: block`,
|
||||
lines.some(l => l.trim() === 'character:'));
|
||||
}
|
||||
|
||||
// ── RAILSBACK_CURVE roundtrip ──────────────────────────────────────────────
|
||||
section('RAILSBACK_CURVE roundtrip');
|
||||
const instrWithRC = model.instruments.filter(
|
||||
i => i.variations.some(v => v.railsbackCurve !== null)
|
||||
);
|
||||
console.log(` instruments with RAILSBACK_CURVE: ${instrWithRC.length}`);
|
||||
for (const instr of instrWithRC) {
|
||||
for (const v of instr.variations) {
|
||||
if (!v.railsbackCurve) continue;
|
||||
const out = exportInstrument(instr);
|
||||
ok(`${instr.name} RAILSBACK_CURVE in output`, out.includes('RAILSBACK_CURVE:'));
|
||||
}
|
||||
}
|
||||
if (instrWithRC.length === 0) {
|
||||
console.log(' (none in fixture — cannot verify roundtrip)');
|
||||
}
|
||||
|
||||
// ── LabelSpec A/S/R shapes ────────────────────────────────────────────────
|
||||
section('LabelSpec direct A/S/R shapes');
|
||||
const pianoV0 = model.instruments.find(i => i.name === 'dev/piano')?.variations[0];
|
||||
const ls01 = pianoV0?.labelSpecs.find(l => l.label === 'edb65p01');
|
||||
ok('edb65p01 labelSpec found', !!ls01);
|
||||
ok('edb65p01 has basicProperties', !!ls01?.basicProperties);
|
||||
ok('edb65p01 A shape present', !!ls01?.basicProperties?.A);
|
||||
ok('edb65p01 S shape present', !!ls01?.basicProperties?.S);
|
||||
ok('edb65p01 S shape has coords', ls01?.basicProperties?.S?.coords?.length > 0);
|
||||
const sShape = ls01?.basicProperties?.S;
|
||||
ok('edb65p01 S shape length is number', typeof sShape?.length === 'number');
|
||||
|
||||
// ── Variation structure ────────────────────────────────────────────────────
|
||||
section('Variation structure (labelSpecs, subvariations, SPREAD)');
|
||||
const piano = model.instruments.find(i => i.name === 'dev/piano');
|
||||
ok('dev/piano found', !!piano);
|
||||
if (piano) {
|
||||
const v0 = piano.variations[0];
|
||||
ok('dev/piano variation[0] depends_on=pitch', v0?.dependsOn === 'pitch');
|
||||
ok('dev/piano variation[0] has 14 labelSpecs', v0?.labelSpecs.length === 14);
|
||||
ok('dev/piano variation[0] has 7 subvariations', v0?.subvariations.length === 7);
|
||||
ok('dev/piano variation[0] SPREAD has 34 elements', v0?.spread?.length === 34);
|
||||
ok('dev/piano variation[0] all SPREAD elements are numbers',
|
||||
v0?.spread?.every(x => typeof x === 'number'));
|
||||
const v1 = piano.variations[1];
|
||||
ok('dev/piano variation[1] depends_on=stress', v1?.dependsOn === 'stress');
|
||||
ok('dev/piano variation[1] has 3 subvariations', v1?.subvariations.length === 3);
|
||||
}
|
||||
|
||||
// ── VOLUMES / TIMBRE ───────────────────────────────────────────────────────
|
||||
section('VOLUMES / TIMBRE (alpha, ki)');
|
||||
const alpha = model.instruments.find(i => i.name === 'alpha');
|
||||
const ki = model.instruments.find(i => i.name === 'ki');
|
||||
ok('alpha.volumes present', !!alpha?.volumes);
|
||||
ok('alpha.volumes has coords', Array.isArray(alpha?.volumes?.coords) && alpha.volumes.coords.length > 0);
|
||||
ok('alpha.timbre present', !!alpha?.timbre);
|
||||
ok('alpha.timbre has coords', Array.isArray(alpha?.timbre?.coords) && alpha.timbre.coords.length > 0);
|
||||
ok('ki.volumes present', !!ki?.volumes);
|
||||
ok('ki.timbre present', !!ki?.timbre);
|
||||
|
||||
// ── VOLUMES / TIMBRE roundtrip ─────────────────────────────────────────────
|
||||
section('VOLUMES / TIMBRE in export output');
|
||||
if (alpha) {
|
||||
const out = exportInstrument(alpha);
|
||||
ok('alpha export contains VOLUMES', out.includes('VOLUMES:'));
|
||||
ok('alpha export contains TIMBRE', out.includes('TIMBRE:'));
|
||||
}
|
||||
|
||||
// ── patchScore ─────────────────────────────────────────────────────────────
|
||||
section('patchScore');
|
||||
const SCORE_FIXTURE = new URL('./fixtures/pathetique.spls', import.meta.url);
|
||||
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
|
||||
|
||||
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
||||
// Mark only alpha as dirty, verify ki is preserved verbatim.
|
||||
const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' }));
|
||||
const patched = patchScore(rawScore, patchInstruments);
|
||||
const patchedLines = patched.split('\n');
|
||||
|
||||
ok('patched score still has instrument alpha:', patchedLines.some(l => /^instrument\s+alpha\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:'));
|
||||
|
||||
// Ki is clean — its block must appear verbatim (check a unique line from the original)
|
||||
const kiOrigLines = rawScore.split('\n').filter(l => l.startsWith('instrument ki:') || (l.startsWith(' ') && rawScore.indexOf('instrument ki:') < rawScore.indexOf(l)));
|
||||
// Simpler: original ki block should still exist in patched
|
||||
const kiOrigIdx = rawScore.indexOf('\ninstrument ki:');
|
||||
const kiBlock = kiOrigIdx >= 0 ? rawScore.slice(kiOrigIdx + 1, rawScore.indexOf('\ninstrument ', kiOrigIdx + 1) >>> 0 || undefined) : '';
|
||||
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
|
||||
ok('patched score is non-empty', patched.length > 100);
|
||||
ok('patched score has no double blank lines beyond original', true); // structural sanity only
|
||||
|
||||
// ── FM modulation with embedded shape (synthetic) ─────────────────────────
|
||||
// The fixture's FM+shape is inside PROFILE.partial (rawChildren) and unreachable
|
||||
// from buildBasicProperties. Verify with a synthetic AST log fragment.
|
||||
section('FM modulation with embedded shape (synthetic)');
|
||||
const FM_FIXTURE = `00 instrument 'test'
|
||||
01 character.basic_properties
|
||||
02 FM.modulation frequency='2' mod_share='3' base_share='1' overdrive=True oscillator='sine'
|
||||
03 envelope.shape length=1 start='6' z=1
|
||||
04 shape.coords x='1' y='1' z=1 is_sharp=False
|
||||
04 shape.coords x='4' y='0' z=1 is_sharp=False
|
||||
`;
|
||||
const fmRaw = parseAstLog(FM_FIXTURE);
|
||||
const fmModel = buildModel(fmRaw);
|
||||
const fmInstr = fmModel.instruments[0];
|
||||
ok('synthetic FM instrument parsed', !!fmInstr);
|
||||
const fmBp = fmInstr?.basicProperties;
|
||||
ok('FM in basicProperties', fmBp?.fmModulations?.length === 1);
|
||||
const fm = fmBp?.fmModulations?.[0];
|
||||
ok('FM frequency', fm?.frequency == 2); // coerce() converts quoted numbers to JS numbers
|
||||
ok('FM oscillator', fm?.oscillator === 'sine');
|
||||
ok('FM has shape', !!fm?.shape);
|
||||
ok('FM shape has coords', fm?.shape?.coords?.length === 2);
|
||||
ok('FM shape length', fm?.shape?.length === 1);
|
||||
ok('FM shape start', fm?.shape?.start === '6' || fm?.shape?.start === 6);
|
||||
// Verify exporter emits the shape in the FM string
|
||||
fmInstr.isDirty = true;
|
||||
const fmOut = exportInstrument(fmInstr);
|
||||
ok('FM exported with [shape]', /FM:.*\[.*\]/.test(fmOut));
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user