Compare commits

...

23 Commits

Author SHA1 Message Date
Florian "flowdy" Heß
3cd66f1e10 Fixed fixtures/ast.log: object hierarchy inside bar 2026-06-25 23:21:07 +02:00
c0dev0id
b745c3e6ad Fix url_for: rename send_audio_generated to rendered_audio 2026-06-24 21:54:32 +02:00
c0dev0id
be338c2de0 Add audiowidget endpoint: Blueprint renders audiowidget.tmpl, PaneCP fetches it on synthesis completion 2026-06-24 18:52:26 +02:00
c0dev0id
06c077b7f8 Remove audiowidget.tmpl.stub: audio rendering is handled by the Vue app, not Neusician templates 2026-06-24 18:44:19 +02:00
c0dev0id
c7c246c32d Fix endpoint names in url_for() calls; remove fallback URLs from api.js 2026-06-24 18:24:08 +02:00
c0dev0id
c4804f0b55 Fix hardcoded API paths: inject URLs via url_for() in template, read from window.NEUSICIAN_URLS in api.js 2026-06-24 18:19:56 +02:00
c0dev0id
097d4488ef Export metadata edits (title, composer, source, encrypter) via patchScore 2026-06-24 17:52:25 +02:00
c0dev0id
720a0b6b25 Add missing tests: AM, patchBarMeta, stressorToString, multi-variation, for_value, DEBUG skipping, serializeModulation edge cases 2026-06-24 16:56:45 +02:00
c0dev0id
89198ec37e Fix serializeModulation field names; add mod:base test assertion 2026-06-24 16:41:12 +02:00
c0dev0id
e4b5cf6887 Rename serializeFm to serializeModulation 2026-06-24 16:22:50 +02:00
c0dev0id
923aa5d41a Add AM modulation support to parser and exporter 2026-06-24 16:19:24 +02:00
c0dev0id
1706404b7e Simplify: remove dead code and redundancy across vue app
- Delete ObjectBasic (defined, never imported)
- ObjectShort: remove unused node prop
- exporter: export stressorToString; use in patchBarMeta (removes inline duplicate)
- PaneFO: import stressorToString from exporter; extract H4 style constant,
  shapeSection() helper, instrOnChange() helper; remove apply param from
  makeChangeHandler (was always () => markDirty()); remove unnecessary ?.
  on always-initialized arrays (variations, labelSpecs, subvariations)
- PaneSubObjects: explicit hasChildren on all item kinds; bars at score
  level get hasChildren based on voices; remove stale kind-list fallback
- ast-parser coerce(): remove unreachable s.trim() check ('' handled above)
- PaneCP: fix post-export synthesisStatus reset to use real field names
2026-06-24 16:08:52 +02:00
c0dev0id
9eb4add695 Make bars navigable and editable with _meta export
Navigation:
- SubObjects: bar → voice (with chevron when offsets exist) → offsets
- PaneCP shortView handles voice and offset types

FO pane:
- Bar: editable beats_per_minute, stress_pattern (serialized as
  groups string), upper/lower stress bounds and tempo shape via
  ShapeEditor; sets bar.isDirty on change
- Voice: read-only name, articles, motifs, offset count
- Offset: read-only tick, stem notes and clusters as label strings
  (note re-serialization deferred)

Export:
- exporter.patchScore() now accepts optional bars[] parameter
- Splits rawScoreText by \n---\n; instruments patched in header,
  dirty bar _meta blocks regenerated, voice note lines kept verbatim
- ast-parser buildBar() adds isDirty:false
- PaneCP passes scoreModel.bars to patchScore
2026-06-24 13:25:23 +02:00
c0dev0id
338ea5be49 Fix result display: audio player, error message, correct field names
- Use file_accomplished (not frozen+!error) to gate audio player
- Use errors field (was .error — wrong name, always falsy)
- Render <audio controls> instead of bare download link
- Add audiowidget.tmpl.stub for server-side customization
2026-06-24 13:16:28 +02:00
c0dev0id
c52b0bf9cf Switch to FO pane on any label click in CP or SubObjects panes 2026-06-23 21:36:46 +02:00
c0dev0id
4c392927cf Fix subvariation display, progress bar, and linked-instrument discard
- ast-parser: read for_value= prop for sub-variations (was always null,
  causing fallback to positional "subvariation N" label)
- StatusPoller: calculate progress from currently_rendered_notes /
  notes_in_total; show remaining_time string from status.json
- ShapeEditor/EnvelopeEditor: pass { undo } through onChange so callers
  can revert in-place mutations
- PaneFO: makeChangeHandler stashes undo from info arg; discardEdit
  calls undo() before clearing pendingEdit; depends_on handler provides
  its own undo for the inline node.dependsOn mutation
2026-06-23 21:28:53 +02:00
c0dev0id
2060f109b6 Show numeric sub-variation edge values as bare numbers
Numeric dependsOn values (merge-type variations) are RFC edge values,
not ATTR names. Display them as the number, not as "ATTR: N" or
"subvariation N".
2026-06-23 18:49:17 +02:00
c0dev0id
06b2bab5d3 Redesign PaneCP as vertical focus-path menu; add score metadata to FO pane
PaneCP: replace horizontal breadcrumb with vertical list of short views
for each node in the focus path (type tag + label/identifier). Clicking
any item above current navigates up.

PaneFO: show score info fields (title, composer, source, encrypter) as
editable DL when at score level instead of 'Nothing selected'.
2026-06-23 18:45:13 +02:00
c0dev0id
839ea3f95c Show full score top-level in SubObjects: info, tuning, articles, stage, instruments, bars 2026-06-23 17:38:08 +02:00
c0dev0id
4afabea837 Fix hidden panes: remove duplicate se-pane wrapper from PaneCP and PaneSubObjects
AppShell already wraps each component in div.se-pane; the inner se-pane
had display:none without .active, hiding all content.
Also drop stale movement/part/line/measure bar fields (bar IDs are opaque).
2026-06-23 17:35:29 +02:00
c0dev0id
f318b155cc Fix VOLUMES/TIMBRE/FM placement: variation properties, not instrument-level
Per RFC §3.2.1.3, VOLUMES, TIMBRE and FM are variation properties. The
previous exporter incorrectly appended them to the first ATTR variation;
they now go into a synthetic root variation (no ATTR) as required.
variationLines() handles these fields directly.

Fix test-parser.mjs fixture paths for standalone execution from repo root.
2026-06-22 19:30:45 +02:00
c0dev0id
17f4529658 Add test-parser.mjs fixture-based compliance test suite 2026-06-22 15:48:23 +02:00
c0dev0id
c8b8d03e3f Apply RFC-compliant ast-parser and exporter
- ast-parser: railsbackCurve moved to variation level, FM shape child
  parsing, buildLabelSpec direct A/S/R handling, bar IDs as opaque strings
- exporter: RFC-compact shape format, correct instrument NAME: key,
  patchScore regex fixed, RAILSBACK_CURVE emitted from variationLines
- .gitignore: exclude test-parser.mjs pending author confirmation
2026-06-22 10:52:32 +02:00
17 changed files with 7889 additions and 8626 deletions

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request
from jinja2 import TemplateNotFound
blueprint = Blueprint(
'vue3_neusik',
@@ -14,3 +15,14 @@ def index():
'vue3_neusik/index.html',
import_on_load='true' if request.args.get('import') == '1' else 'false',
)
@blueprint.route('/audiowidget', methods=['GET'])
def audiowidget():
try:
return render_template(
'vue3_neusik/audiowidget.tmpl',
result_url=request.args.get('result_url', ''),
errors=request.args.get('errors', ''),
)
except TemplateNotFound:
return '', 204

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
const U = window.NEUSICIAN_URLS;
export const URLS = U;
function authHeader(credentials) {
if (!credentials) return {};
const b64 = btoa(`${credentials.username}:${credentials.password}`);
@@ -5,7 +9,7 @@ function authHeader(credentials) {
}
export async function fetchAstLog(credentials) {
const res = await fetch('/sompyle/astlog', {
const res = await fetch(U.astlog, {
headers: authHeader(credentials),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
@@ -13,7 +17,7 @@ export async function fetchAstLog(credentials) {
}
export async function fetchScoreText(credentials) {
const res = await fetch('/sompyle/score.spls', {
const res = await fetch(U.score, {
headers: authHeader(credentials),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
@@ -21,7 +25,7 @@ export async function fetchScoreText(credentials) {
}
export async function putScoreText(text, credentials) {
const res = await fetch('/sompyle/score.spls', {
const res = await fetch(U.score, {
method: 'PUT',
headers: {
...authHeader(credentials),
@@ -34,9 +38,18 @@ export async function putScoreText(text, credentials) {
}
export async function fetchStatus(credentials) {
const res = await fetch('/sompyle/status.json', {
const res = await fetch(U.status, {
headers: authHeader(credentials),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
export async function fetchAudioWidget(resultUrl, errors) {
const params = new URLSearchParams();
if (resultUrl) params.set('result_url', resultUrl);
if (errors) params.set('errors', errors);
const res = await fetch(`${U.audiowidget}?${params}`);
if (!res.ok || res.status === 204) return '';
return res.text();
}

View File

@@ -7,7 +7,7 @@ function coerce(s) {
if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false;
if (s === '') return s;
const n = Number(s);
if (!isNaN(n) && s.trim() !== '') return n;
if (!isNaN(n)) return n;
return s;
}
@@ -178,10 +178,10 @@ function buildInstrument(node) {
isDirty: false,
variations: [],
basicProperties: null,
railsbackCurve: null,
volumes: null,
timbre: null,
fmModulations: [],
amModulations: [],
rawChildren: [],
};
@@ -193,9 +193,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;
@@ -205,6 +202,9 @@ function buildInstrument(node) {
case 'FM.modulation':
instr.fmModulations.push({ ...child.props });
break;
case 'AM.modulation':
instr.amModulations.push({ ...child.props });
break;
default:
instr.rawChildren.push(buildGeneric(child));
}
@@ -216,11 +216,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 +240,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));
}
@@ -253,6 +257,7 @@ function buildBasicProperties(node) {
A: null, S: null, R: null,
oscillator: null,
fmModulations: [],
amModulations: [],
rawChildren: [],
};
@@ -267,7 +272,15 @@ 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 if (child.parentSlot === 'AM' && child.slot === 'modulation') {
const am = { ...child.props };
const envChild = child.children.find(c => c.slot === 'shape');
if (envChild) am.shape = buildShape(envChild);
bp.amModulations.push(am);
} else {
bp.rawChildren.push(buildGeneric(child));
}
@@ -284,13 +297,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 +323,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 +335,10 @@ 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] ?? '',
isDirty: false,
stressor: null,
tempoShape: null,
tempoLevels: null,

View File

@@ -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)

View File

@@ -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,

View File

@@ -1,15 +0,0 @@
import { h } from 'vue';
// Inline identifier + a few key props — used for list rows.
export const ObjectBasic = {
props: ['label', 'meta'], // meta: array of {key, value} pairs
setup(props) {
return () => h('div', { class: 'se-object-label' }, [
h('strong', null, props.label),
...(props.meta ?? []).map(({ key, value }) =>
h('span', { style: 'color:#888;margin-left:0.5rem;font-size:0.8em' },
`${key}=${value}`)
),
]);
},
};

View File

@@ -2,7 +2,7 @@ import { h } from 'vue';
// One-line summary row with a drill-down chevron.
export const ObjectShort = {
props: ['node', 'label', 'typeTag', 'focused', 'hasChildren'],
props: ['label', 'typeTag', 'focused', 'hasChildren'],
emits: ['focus', 'drillDown'],
setup(props, { emit }) {
return () => h('li', {

View File

@@ -1,23 +1,57 @@
import { h, ref } from 'vue';
import { fetchScoreText, putScoreText } from '../api.js';
import { h, ref, watch } from 'vue';
import { fetchScoreText, putScoreText, fetchAudioWidget, URLS } 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: [] };
case 'voice':
return { typeTag: 'voice', label: node.name, meta: [] };
case 'offset':
return { typeTag: 'tick', label: String(node.tick ?? '?'), 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('');
const audioWidgetHtml = 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;
}
watch(
() => props.store.synthesisStatus,
async (s) => {
if (!s?.frozen) { audioWidgetHtml.value = ''; return; }
audioWidgetHtml.value = await fetchAudioWidget(
s.file_accomplished ? URLS.result : null,
s.errors ?? null,
);
},
);
async function doExport() {
exportError.value = '';
@@ -25,10 +59,9 @@ export const PaneCP = {
try {
const raw = await fetchScoreText(props.store.credentials);
props.store.rawScoreText = raw;
const patched = patchScore(raw, props.store.scoreModel.instruments);
const patched = patchScore(raw, props.store.scoreModel.instruments, props.store.scoreModel.bars, props.store.scoreModel.info);
await putScoreText(patched, props.store.credentials);
// Start polling
props.store.synthesisStatus = { frozen: false, progress: 0 };
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
} catch (e) {
exportError.value = e.message;
} finally {
@@ -41,56 +74,65 @@ 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 (while running)
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')
// Audio widget (rendered server-side from audiowidget.tmpl)
audioWidgetHtml.value
? h('div', { innerHTML: audioWidgetHtml.value, style: 'margin-top:0.5rem' })
: null,
]);
};

View File

@@ -1,42 +1,66 @@
import { h, ref } from 'vue';
import { ObjectExtended } from './ObjectExtended.js';
import { EnvelopeEditor } from './EnvelopeEditor.js';
import { ShapeEditor } from './ShapeEditor.js';
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
import { stressorToString } from '../exporter.js';
const H4 = { style: 'margin:0 0 0.5rem' };
function parseStressor(str) {
const groups = str.split(';').map(seg =>
seg.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))
).filter(g => g.length > 0);
return groups.length ? { type: 'stressor', groups } : null;
}
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 },
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
{ key: 'name', value: instr.name, editable: false },
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
{ key: 'NOT_CHANGED_SINCE', value: instr.notChangedSince ?? '—', editable: false },
];
}
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) {
if (!shape) return null;
return h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, label),
h(ShapeEditor, { shape, onChange }),
]);
}
export const PaneFO = {
props: ['store'],
setup(props) {
// Pending edit held while the linked-instrument modal is shown.
const pendingEdit = ref(null); // { instr, apply: fn }
const pendingEdit = ref(null); // { instr, undo? }
function focused() {
const fp = props.store.focusPath;
return fp.length ? fp[fp.length - 1] : null;
}
// Returns a change handler that intercepts the first edit to a linked
// instrument and shows the embed-or-discard modal before applying it.
function makeChangeHandler(instr, apply) {
return () => {
// Intercepts the first edit to a linked instrument:
// shows embed-or-discard modal before committing. `info.undo`
// (forwarded from ShapeEditor/EnvelopeEditor) reverts the mutation on discard.
function makeChangeHandler(instr) {
return (info) => {
if (instr.isLinked && !instr.isDirty) {
// Stash the apply callback and show the modal.
pendingEdit.value = { instr, apply };
pendingEdit.value = { instr, undo: info?.undo };
} else {
apply();
instr.isDirty = true;
props.store.markDirty();
}
@@ -47,76 +71,137 @@ export const PaneFO = {
instr.name = instr.name.split('/').pop();
instr.isLinked = false;
instr.isDirty = true;
pendingEdit.value.apply();
pendingEdit.value = null;
props.store.markDirty();
}
function discardEdit() {
pendingEdit.value?.undo?.();
pendingEdit.value = null;
}
function instrOnChange(instr) {
return instr
? makeChangeHandler(instr)
: () => props.store.markDirty();
}
return () => {
const node = focused();
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', H4, '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') {
children.push(
h('h4', { style: 'margin:0 0 0.5rem' }, `Instrument: ${node.name}`),
h('h4', H4, `Instrument: ${node.name}`),
h(ObjectExtended, { fields: instrFields(node), onChange: null }),
);
} else if (node.type === 'variation') {
// Find the ancestor instrument for linked-instrument gating.
const instr = props.store.scoreModel?.instruments.find(
i => i.variations?.includes(node) ||
i.variations?.some(v => v.subvariations?.includes(node))
const instr = props.store.scoreModel.instruments.find(
i => i.variations.includes(node) ||
i.variations.some(v => v.subvariations.includes(node))
) ?? null;
const onChange = instr
? makeChangeHandler(instr, () => { props.store.markDirty(); })
: () => props.store.markDirty();
const onChange = instrOnChange(instr);
children.push(
h('h4', { style: 'margin:0 0 0.5rem' }, 'Variation'),
h('h4', H4, '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 })
: null,
);
} else if (node.type === 'label_spec') {
const instr = props.store.scoreModel?.instruments.find(
i => i.variations?.some(v =>
v.labelSpecs?.includes(node) ||
v.subvariations?.some(sv => sv.labelSpecs?.includes(node))
const instr = props.store.scoreModel.instruments.find(
i => i.variations.some(v =>
v.labelSpecs.includes(node) ||
v.subvariations.some(sv => sv.labelSpecs.includes(node))
)
) ?? null;
const onChange = instr
? makeChangeHandler(instr, () => { props.store.markDirty(); })
: () => props.store.markDirty();
const onChange = instrOnChange(instr);
children.push(
h('h4', { style: 'margin:0 0 0.5rem' }, `Label: ${node.label}`),
h('h4', H4, `Label: ${node.label}`),
node.basicProperties
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
: null,
);
} else if (node.type === 'bar') {
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
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 },
], onChange: null }),
h('h4', H4, `Bar: ${node.id}`),
h(ObjectExtended, {
fields: [
{ key: 'id', value: node.id, editable: false },
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
],
onChange: ({ key, value }) => {
if (key === 'beats_per_minute') node.tempoLevels = isNaN(value) ? null : value;
if (key === 'stress_pattern') node.stressor = parseStressor(value);
markBarDirty();
},
}),
shapeSection('Upper stress bound', node.upperStressBound, markBarDirty),
shapeSection('Lower stress bound', node.lowerStressBound, markBarDirty),
shapeSection('Tempo shape', node.tempoShape, markBarDirty),
);
} else if (node.type === 'voice') {
children.push(
h('h4', H4, `Voice: ${node.name}`),
h(ObjectExtended, {
fields: [
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
{ key: 'motifs', value: node.motifs.join(', ') || '—', editable: false },
{ key: 'offsets', value: String(node.offsets.length), editable: false },
],
onChange: null,
}),
);
} else if (node.type === 'offset') {
const noteStr = n => `${n.pitch}${n.effLength != null ? ' ' + n.effLength : ''}`;
const clusterStr = c => c.notes.length
? c.notes.map(n => `${n.letter ?? ''}${n.shift != null ? n.shift : ''}${n.length != null ? ' ' + n.length : ''}`).join(', ')
: `cluster[${c.index}]`;
const noteItem = (text, i) => h('li', { class: 'se-object-item', key: i },
h('span', { class: 'se-object-label' }, text));
children.push(
h('h4', H4, `Tick: ${node.tick}`),
h(ObjectExtended, {
fields: [{ key: 'tick', value: node.tick, editable: false }],
onChange: null,
}),
node.stemNotes.length ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Stem notes'),
h('ul', { class: 'se-object-list' }, node.stemNotes.map((n, i) => noteItem(noteStr(n), i))),
]) : null,
node.clusters.length ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Clusters'),
h('ul', { class: 'se-object-list' }, node.clusters.map((c, i) => noteItem(clusterStr(c), i))),
]) : null,
);
} else {
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
@@ -128,8 +213,8 @@ export const PaneFO = {
pendingEdit.value
? h(LinkedInstrumentModal, {
instrumentName: pendingEdit.value.instr.name,
onEmbed: () => embedInstrument(pendingEdit.value.instr),
onDiscard: discardEdit,
onEmbed: () => embedInstrument(pendingEdit.value.instr),
onDiscard: discardEdit,
})
: null,
]);

View File

@@ -1,9 +1,8 @@
import { h } from 'vue';
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,31 +12,51 @@ 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: Object.keys(b.voices).length > 0 });
return items;
}
if (node.type === 'instrument') {
return node.variations.map((v, idx) => ({
kind: 'variation',
node: v,
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
hasChildren: true,
}));
}
if (node.type === 'variation') {
return [
...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}`,
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false,
})),
...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') {
return Object.entries(node.voices).map(([name, v]) => ({
kind: 'voice', node: v, label: name,
kind: 'voice', node: v, label: name, hasChildren: v.offsets.length > 0,
}));
}
if (node.type === 'voice') {
return node.offsets.map((o, idx) => ({
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: false,
}));
}
return [];
@@ -46,22 +65,21 @@ 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,
node: item.node,
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,
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
})
)),
]);
))
);
};
},
};

View File

@@ -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) }, '✕')),
])

View File

@@ -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 020%: 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),

View File

@@ -1,138 +1,188 @@
// 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 serializeModulation(m) {
let s = String(m.frequency ?? '');
if (m.oscillator) s += `@${m.oscillator}`;
if (m.shape) s += `[${serializeShape(m.shape)}]`;
s += `;${m.mod_share ?? ''}:${m.base_share ?? ''}`;
if (m.init_phase != null) s += m.init_phase >= 0 ? `+${m.init_phase}` : String(m.init_phase);
return s;
}
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: "${serializeModulation(fm)}"`);
for (const am of (bp.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
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: "${serializeModulation(fm)}"`);
for (const am of (v.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv));
return lines;
}
// ── Instrument character block ─────────────────────────────────────────────
// VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser
// stores them on the instrument because they appear at depth 01 (root variation
// is implicit when no character: wrapper exists). Promote them into a synthetic
// root variation here so the export structure is RFC-correct.
function instrCharacterLines(instr) {
const variations = instr.variations ?? [];
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
(instr.fmModulations ?? []).length > 0 ||
(instr.amModulations ?? []).length > 0;
const syntheticRoot = hasRootProps
? {
basicProperties: instr.basicProperties,
labelSpecs: [], subvariations: [], spread: null,
dependsOn: null, railsbackCurve: null,
volumes: instr.volumes,
timbre: instr.timbre,
fmModulations: instr.fmModulations ?? [],
amModulations: instr.amModulations ?? [],
}
: null;
const allVariations = [
...(syntheticRoot ? [syntheticRoot] : []),
...variations,
];
if (allVariations.length <= 1) {
const vLines = allVariations.length ? variationLines(allVariations[0]) : [];
return vLines.map(l => ` ${l}`);
}
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
const result = [];
for (const v of allVariations) {
const vLines = variationLines(v);
if (!vLines.length) continue;
result.push(` - ${vLines[0]}`);
for (const l of vLines.slice(1)) result.push(` ${l}`);
}
return result;
}
// ── Instrument ────────────────────────────────────────────────────────────
// RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'"
export function exportInstrument(instr) {
const lines = [];
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 and dirty bar _meta blocks.
// Voice note content in bar documents is 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 META_KEYS = ['title', 'composer', 'source', 'encrypter'];
const lines = rawScoreText.split('\n');
function patchMetadata(text, info) {
if (!info) return text;
const lines = text.split('\n');
const replaced = new Set();
const out = lines.map(line => {
for (const key of META_KEYS) {
if (line.startsWith(key + ':') && info[key] != null && info[key] !== '') {
replaced.add(key);
return `${key}: ${info[key]}`;
}
}
return line;
});
const prepend = META_KEYS
.filter(k => !replaced.has(k) && info[k] != null && info[k] !== '')
.map(k => `${k}: ${info[k]}`);
if (prepend.length) out.unshift(...prepend);
return out.join('\n');
}
function patchInstrumentHeader(text, instruments) {
const lines = text.split('\n');
const result = [];
const instrMap = {};
for (const instr of instruments) {
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+'?([^']+)'?/);
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));
@@ -149,3 +199,67 @@ export function patchScore(rawScoreText, instruments) {
return result.join('\n');
}
export function stressorToString(s) {
if (!s?.groups?.length) return '';
return s.groups.map(g => g.join(',')).join(';');
}
function patchBarMeta(doc, bar) {
const props = [];
const sp = stressorToString(bar.stressor);
if (sp) props.push(` stress_pattern: ${sp}`);
if (bar.tempoLevels != null)
props.push(` beats_per_minute: ${bar.tempoLevels}`);
const ub = serializeShape(bar.upperStressBound);
if (ub) props.push(` upper_stress_bound: ${ub}`);
const lb = serializeShape(bar.lowerStressBound);
if (lb) props.push(` lower_stress_bound: ${lb}`);
if (bar.tempoShape) { const ts = serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
const lines = doc.split('\n');
const out = [];
let i = 0;
let replaced = false;
while (i < lines.length) {
if (lines[i] === '_meta:') {
replaced = true;
i++;
while (i < lines.length && lines[i].startsWith(' ')) i++;
if (props.length) { out.push('_meta:'); out.push(...props); }
} else {
out.push(lines[i]);
i++;
}
}
if (!replaced && props.length) {
const idIdx = out.findIndex(l => /^_id:/.test(l));
if (idIdx !== -1) out.splice(idIdx + 1, 0, '_meta:', ...props);
}
return out.join('\n');
}
export function patchScore(rawScoreText, instruments, bars = [], info = null) {
const SEP = '\n---\n';
const [header, ...barDocs] = rawScoreText.split(SEP);
const patchedHeader = patchInstrumentHeader(patchMetadata(header, info), instruments);
if (!barDocs.length) return patchedHeader;
const barMap = {};
for (const bar of bars) barMap[bar.id] = bar;
const patchedBarDocs = barDocs.map(doc => {
const m = doc.match(/^_id:\s*(\S+)/m);
if (!m) return doc;
const bar = barMap[m[1]];
if (!bar?.isDirty) return doc;
return patchBarMeta(doc, bar);
});
return [patchedHeader, ...patchedBarDocs].join(SEP);
}

View File

@@ -0,0 +1,18 @@
{# audiowidget.tmpl.stub — copy to audiowidget.tmpl in the same directory to activate.
Customise to match your site's look and feel.
Context variables (passed as query params by the score editor):
result_url URL of the rendered MP3, or empty string
errors synthesis error message, or empty string
#}
{% if errors %}
<div class="se-error">{{ errors }}</div>
{% elif result_url %}
<figure class="audio-result">
<figcaption>Rendered result</figcaption>
<audio controls preload="metadata" style="display:block;width:100%">
<source src="{{ result_url }}" type="audio/mpeg">
<a href="{{ result_url }}">Download MP3</a>
</audio>
</figure>
{% endif %}

View File

@@ -9,6 +9,16 @@
<div id="score-editor-app"
data-import-on-load="{{ import_on_load }}">
</div>
<script>
window.NEUSICIAN_URLS = {
astlog: "{{ url_for('astlog') }}",
score: "{{ url_for('score') }}",
status: "{{ url_for('statusjson') }}",
result: "{{ url_for('rendered_audio') }}",
submit: "{{ url_for('public-yaml-acceptor') }}",
audiowidget: "{{ url_for('vue3_neusik.audiowidget') }}"
};
</script>
<script type="importmap">
{
"imports": {

434
test-parser.mjs Normal file
View File

@@ -0,0 +1,434 @@
#!/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, stressorToString } 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));
ok('FM exported with mod:base', /FM:.*;\d+:\d+/.test(fmOut));
// ── DEBUG line skipping ────────────────────────────────────────────────────
section('DEBUG line skipping');
const DEBUG_FIXTURE = `00 instrument 'dbg'
01 # DEBUG missing level: [('character', [])]
01 character.basic_properties
02 A.shape length=1
03 shape.coords x='1' y='10' z=1 is_sharp=False
03 shape.coords x='2' y='0' z=1 is_sharp=False
`;
const dbgModel = buildModel(parseAstLog(DEBUG_FIXTURE));
const dbgInstr = dbgModel.instruments[0];
ok('DEBUG line skipped — instrument parsed', !!dbgInstr);
ok('DEBUG line skipped — A shape present', !!dbgInstr?.basicProperties?.A);
ok('DEBUG line skipped — A shape has 2 coords', dbgInstr?.basicProperties?.A?.coords?.length === 2);
// ── parseRest edge cases ───────────────────────────────────────────────────
section('parseRest edge cases');
const PARSE_FIXTURE = `00 instrument 'pi'
01 character.variation depends_on='pitch'
02 variation.label_spec 'myLabel' foo=True bar='hello world'
`;
const parseModel = buildModel(parseAstLog(PARSE_FIXTURE));
const parseInstr = parseModel.instruments[0];
const parseLs = parseInstr?.variations[0]?.labelSpecs[0];
ok('quoted positional parsed', parseLs?.label === 'myLabel');
ok('bool prop coerced', parseInstr?.variations[0]?.props?.depends_on === 'pitch' || parseInstr?.variations[0]?.dependsOn === 'pitch');
// ── Sub-variation for_value ────────────────────────────────────────────────
section('Sub-variation for_value');
const SV_FIXTURE = `00 instrument 'piano'
01 character.variation depends_on='pitch'
02 variation.subvariation for_value='440.0'
02 variation.subvariation for_value='880.0'
`;
const svModel = buildModel(parseAstLog(SV_FIXTURE));
const svInstr = svModel.instruments[0];
const svs = svInstr?.variations[0]?.subvariations;
ok('two subvariations parsed', svs?.length === 2);
ok('first subvariation dependsOn=440', svs?.[0]?.dependsOn == 440);
ok('second subvariation dependsOn=880', svs?.[1]?.dependsOn == 880);
// ── AM modulation ─────────────────────────────────────────────────────────
section('AM modulation (synthetic)');
const AM_FIXTURE = `00 instrument 'test'
01 character.basic_properties
02 AM.modulation frequency='3' mod_share='2' base_share='5' overdrive=True oscillator='sawtooth'
03 envelope.shape length=2 start='0' z=1
04 shape.coords x='1' y='1' z=1 is_sharp=False
04 shape.coords x='2' y='0' z=1 is_sharp=False
`;
const amRaw = parseAstLog(AM_FIXTURE);
const amModel = buildModel(amRaw);
const amInstr = amModel.instruments[0];
ok('synthetic AM instrument parsed', !!amInstr);
const amBp = amInstr?.basicProperties;
ok('AM in basicProperties', amBp?.amModulations?.length === 1);
const am = amBp?.amModulations?.[0];
ok('AM frequency', am?.frequency == 3);
ok('AM oscillator', am?.oscillator === 'sawtooth');
ok('AM mod_share', am?.mod_share == 2);
ok('AM base_share', am?.base_share == 5);
ok('AM has shape', !!am?.shape);
ok('AM shape coords', am?.shape?.coords?.length === 2);
amInstr.isDirty = true;
const amOut = exportInstrument(amInstr);
ok('AM exported', amOut.includes('AM:'));
ok('AM exported with @sawtooth', /AM:.*@sawtooth/.test(amOut));
ok('AM exported with mod:base', /AM:.*;\d+:\d+/.test(amOut));
ok('AM exported with [shape]', /AM:.*\[.*\]/.test(amOut));
// ── serializeModulation edge cases ─────────────────────────────────────────
section('serializeModulation edge cases');
const MOD_INIT_FIXTURE = `00 instrument 'test'
01 character.basic_properties
02 FM.modulation frequency='5' mod_share='1' base_share='2' overdrive=True oscillator='square' init_phase='+3'
`;
const modModel = buildModel(parseAstLog(MOD_INIT_FIXTURE));
const modInstr = modModel.instruments[0];
modInstr.isDirty = true;
const modOut = exportInstrument(modInstr);
ok('FM with non-sine oscillator exported', /FM:.*@square/.test(modOut));
ok('FM with init_phase exported', /FM:.*;.*[+-]\d+/.test(modOut));
// ── stressorToString ───────────────────────────────────────────────────────
section('stressorToString');
ok('null stressor → empty string', stressorToString(null) === '');
ok('empty groups → empty string', stressorToString({ groups: [] }) === '');
ok('single group', stressorToString({ groups: [[1, 2, 3]] }) === '1,2,3');
ok('multiple groups', stressorToString({ groups: [[1, 2], [3]] }) === '1,2;3');
ok('single-element groups', stressorToString({ groups: [[4], [2], [1]] }) === '4;2;1');
// ── multiple-variation export (YAML sequence) ──────────────────────────────
section('Multiple-variation export');
const MV_FIXTURE = `00 instrument 'mv'
01 character.variation
02 A.shape length=1
03 shape.coords x='1' y='10' z=1 is_sharp=False
03 shape.coords x='2' y='0' z=1 is_sharp=False
01 character.variation depends_on='stress'
02 A.shape length=2
03 shape.coords x='1' y='5' z=1 is_sharp=False
03 shape.coords x='2' y='0' z=1 is_sharp=False
`;
const mvModel = buildModel(parseAstLog(MV_FIXTURE));
const mvInstr = mvModel.instruments[0];
ok('two variations parsed', mvInstr?.variations?.length === 2);
mvInstr.isDirty = true;
const mvOut = exportInstrument(mvInstr);
ok('multi-variation: has character:', mvOut.includes('character:'));
ok('multi-variation: YAML sequence (- )', /^\s+- /m.test(mvOut));
ok('multi-variation: second variation has ATTR:', mvOut.includes('ATTR: stress'));
// ── patchBarMeta ───────────────────────────────────────────────────────────
section('patchBarMeta (via patchScore)');
const RAW_SCORE_WITH_BARS = `instrument alpha:
character:
A: "1:0,10;1,0"
---
_id: 001P1L1M1
_meta:
stress_pattern: 1,2;3
beats_per_minute: 120
voice soprano:
- C4 4
---
_id: 001P1L1M2
_meta:
beats_per_minute: 100
voice soprano:
- D4 4
`;
const dirtyBar = {
id: '001P1L1M1',
isDirty: true,
stressor: { groups: [[2, 3], [1]] },
tempoLevels: 140,
upperStressBound: null,
lowerStressBound: null,
tempoShape: null,
};
const cleanBar = {
id: '001P1L1M2',
isDirty: false,
stressor: null, tempoLevels: null,
upperStressBound: null, lowerStressBound: null, tempoShape: null,
};
const barPatched = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
const barPatchedLines = barPatched.split('\n');
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 voice content preserved', barPatched.includes('- C4 4'));
ok('clean bar unchanged', barPatched.includes('beats_per_minute: 100'));
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
ok('document separators preserved', (barPatched.match(/\n---\n/g) ?? []).length === 2);
// ── patchScore metadata ────────────────────────────────────────────────────
section('patchScore metadata');
const META_SCORE = `title: Old Title
composer: Old Composer
source: Some Book
instrument alpha:
character:
A: "1:0,10;1,0"
`;
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
const metaPatched = patchScore(META_SCORE, [], [], updatedInfo);
ok('existing title replaced', metaPatched.includes('title: New Title'));
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
ok('empty value leaves existing line', metaPatched.includes('source: Some Book'));
ok('new key prepended', metaPatched.includes('encrypter: Me'));
ok('instrument block untouched', metaPatched.includes('instrument alpha:'));
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
const noTitlePatched = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' });
ok('missing title prepended', noTitlePatched.includes('title: Fugue'));
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
const nullInfoPatched = patchScore(META_SCORE, [], [], null);
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
// ── Summary ────────────────────────────────────────────────────────────────
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
process.exit(fail > 0 ? 1 : 0);