forked from flow/vue3js-app-proposal-for-sdk-claude
Compare commits
21 Commits
17f4529658
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd66f1e10 | ||
|
|
b745c3e6ad | ||
|
|
be338c2de0 | ||
|
|
06c077b7f8 | ||
|
|
c7c246c32d | ||
|
|
c4804f0b55 | ||
|
|
097d4488ef | ||
|
|
720a0b6b25 | ||
|
|
89198ec37e | ||
|
|
e4b5cf6887 | ||
|
|
923aa5d41a | ||
|
|
1706404b7e | ||
|
|
9eb4add695 | ||
|
|
338ea5be49 | ||
|
|
c52b0bf9cf | ||
|
|
4c392927cf | ||
|
|
2060f109b6 | ||
|
|
06b2bab5d3 | ||
|
|
839ea3f95c | ||
|
|
4afabea837 | ||
|
|
f318b155cc |
12
__init__.py
12
__init__.py
@@ -1,4 +1,5 @@
|
|||||||
from flask import Blueprint, render_template, request
|
from flask import Blueprint, render_template, request
|
||||||
|
from jinja2 import TemplateNotFound
|
||||||
|
|
||||||
blueprint = Blueprint(
|
blueprint = Blueprint(
|
||||||
'vue3_neusik',
|
'vue3_neusik',
|
||||||
@@ -14,3 +15,14 @@ def index():
|
|||||||
'vue3_neusik/index.html',
|
'vue3_neusik/index.html',
|
||||||
import_on_load='true' if request.args.get('import') == '1' else 'false',
|
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
|
||||||
|
|||||||
15197
fixtures/ast.log
15197
fixtures/ast.log
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
|||||||
|
const U = window.NEUSICIAN_URLS;
|
||||||
|
|
||||||
|
export const URLS = U;
|
||||||
|
|
||||||
function authHeader(credentials) {
|
function authHeader(credentials) {
|
||||||
if (!credentials) return {};
|
if (!credentials) return {};
|
||||||
const b64 = btoa(`${credentials.username}:${credentials.password}`);
|
const b64 = btoa(`${credentials.username}:${credentials.password}`);
|
||||||
@@ -5,7 +9,7 @@ function authHeader(credentials) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAstLog(credentials) {
|
export async function fetchAstLog(credentials) {
|
||||||
const res = await fetch('/sompyle/astlog', {
|
const res = await fetch(U.astlog, {
|
||||||
headers: authHeader(credentials),
|
headers: authHeader(credentials),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
@@ -13,7 +17,7 @@ export async function fetchAstLog(credentials) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchScoreText(credentials) {
|
export async function fetchScoreText(credentials) {
|
||||||
const res = await fetch('/sompyle/score.spls', {
|
const res = await fetch(U.score, {
|
||||||
headers: authHeader(credentials),
|
headers: authHeader(credentials),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
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) {
|
export async function putScoreText(text, credentials) {
|
||||||
const res = await fetch('/sompyle/score.spls', {
|
const res = await fetch(U.score, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
...authHeader(credentials),
|
...authHeader(credentials),
|
||||||
@@ -34,9 +38,18 @@ export async function putScoreText(text, credentials) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStatus(credentials) {
|
export async function fetchStatus(credentials) {
|
||||||
const res = await fetch('/sompyle/status.json', {
|
const res = await fetch(U.status, {
|
||||||
headers: authHeader(credentials),
|
headers: authHeader(credentials),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
return res.json();
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function coerce(s) {
|
|||||||
if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false;
|
if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false;
|
||||||
if (s === '') return s;
|
if (s === '') return s;
|
||||||
const n = Number(s);
|
const n = Number(s);
|
||||||
if (!isNaN(n) && s.trim() !== '') return n;
|
if (!isNaN(n)) return n;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +181,7 @@ function buildInstrument(node) {
|
|||||||
volumes: null,
|
volumes: null,
|
||||||
timbre: null,
|
timbre: null,
|
||||||
fmModulations: [],
|
fmModulations: [],
|
||||||
|
amModulations: [],
|
||||||
rawChildren: [],
|
rawChildren: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +202,9 @@ function buildInstrument(node) {
|
|||||||
case 'FM.modulation':
|
case 'FM.modulation':
|
||||||
instr.fmModulations.push({ ...child.props });
|
instr.fmModulations.push({ ...child.props });
|
||||||
break;
|
break;
|
||||||
|
case 'AM.modulation':
|
||||||
|
instr.amModulations.push({ ...child.props });
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
instr.rawChildren.push(buildGeneric(child));
|
instr.rawChildren.push(buildGeneric(child));
|
||||||
}
|
}
|
||||||
@@ -212,7 +216,7 @@ function buildInstrument(node) {
|
|||||||
function buildVariation(node) {
|
function buildVariation(node) {
|
||||||
const v = {
|
const v = {
|
||||||
type: 'variation',
|
type: 'variation',
|
||||||
dependsOn: node.props.depends_on ?? null,
|
dependsOn: node.props.depends_on ?? node.props.for_value ?? null,
|
||||||
basicProperties: null,
|
basicProperties: null,
|
||||||
labelSpecs: [],
|
labelSpecs: [],
|
||||||
subvariations: [],
|
subvariations: [],
|
||||||
@@ -253,6 +257,7 @@ function buildBasicProperties(node) {
|
|||||||
A: null, S: null, R: null,
|
A: null, S: null, R: null,
|
||||||
oscillator: null,
|
oscillator: null,
|
||||||
fmModulations: [],
|
fmModulations: [],
|
||||||
|
amModulations: [],
|
||||||
rawChildren: [],
|
rawChildren: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +276,11 @@ function buildBasicProperties(node) {
|
|||||||
const envChild = child.children.find(c => c.slot === 'shape');
|
const envChild = child.children.find(c => c.slot === 'shape');
|
||||||
if (envChild) fm.shape = buildShape(envChild);
|
if (envChild) fm.shape = buildShape(envChild);
|
||||||
bp.fmModulations.push(fm);
|
bp.fmModulations.push(fm);
|
||||||
|
} else if (child.parentSlot === 'AM' && child.slot === 'modulation') {
|
||||||
|
const am = { ...child.props };
|
||||||
|
const envChild = child.children.find(c => c.slot === 'shape');
|
||||||
|
if (envChild) am.shape = buildShape(envChild);
|
||||||
|
bp.amModulations.push(am);
|
||||||
} else {
|
} else {
|
||||||
bp.rawChildren.push(buildGeneric(child));
|
bp.rawChildren.push(buildGeneric(child));
|
||||||
}
|
}
|
||||||
@@ -328,6 +338,7 @@ function buildBar(node) {
|
|||||||
const bar = {
|
const bar = {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
id: node.positionals[0] ?? '',
|
id: node.positionals[0] ?? '',
|
||||||
|
isDirty: false,
|
||||||
stressor: null,
|
stressor: null,
|
||||||
tempoShape: null,
|
tempoShape: null,
|
||||||
tempoLevels: null,
|
tempoLevels: null,
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ export const AppShell = {
|
|||||||
// Pane area
|
// Pane area
|
||||||
h('div', { class: 'se-pane-area' }, [
|
h('div', { class: 'se-pane-area' }, [
|
||||||
h('div', { class: ['se-pane', activePane.value === 'cp' ? 'active' : null] },
|
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('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] },
|
||||||
h(PaneFO, { store })),
|
h(PaneFO, { store })),
|
||||||
h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] },
|
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)
|
// Handle bar (tab switcher at bottom)
|
||||||
|
|||||||
@@ -24,12 +24,9 @@ export const EnvelopeEditor = {
|
|||||||
setup(props) {
|
setup(props) {
|
||||||
function toggle(section) {
|
function toggle(section) {
|
||||||
const bp = props.basicProperties;
|
const bp = props.basicProperties;
|
||||||
if (bp[section]) {
|
const old = bp[section];
|
||||||
bp[section] = null;
|
bp[section] = old ? null : defaultShape(section);
|
||||||
} else {
|
props.onChange?.({ undo: () => { bp[section] = old; } });
|
||||||
bp[section] = defaultShape(section);
|
|
||||||
}
|
|
||||||
props.onChange?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -57,7 +54,7 @@ export const EnvelopeEditor = {
|
|||||||
? h('div', { class: disabled ? 'se-envelope-disabled' : null }, [
|
? h('div', { class: disabled ? 'se-envelope-disabled' : null }, [
|
||||||
h(ShapeEditor, {
|
h(ShapeEditor, {
|
||||||
shape: bp[key],
|
shape: bp[key],
|
||||||
onChange: props.onChange,
|
onChange: info => props.onChange?.(info),
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -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}`)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,7 @@ import { h } from 'vue';
|
|||||||
|
|
||||||
// One-line summary row with a drill-down chevron.
|
// One-line summary row with a drill-down chevron.
|
||||||
export const ObjectShort = {
|
export const ObjectShort = {
|
||||||
props: ['node', 'label', 'typeTag', 'focused', 'hasChildren'],
|
props: ['label', 'typeTag', 'focused', 'hasChildren'],
|
||||||
emits: ['focus', 'drillDown'],
|
emits: ['focus', 'drillDown'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
return () => h('li', {
|
return () => h('li', {
|
||||||
|
|||||||
@@ -1,23 +1,57 @@
|
|||||||
import { h, ref } from 'vue';
|
import { h, ref, watch } from 'vue';
|
||||||
import { fetchScoreText, putScoreText } from '../api.js';
|
import { fetchScoreText, putScoreText, fetchAudioWidget, URLS } from '../api.js';
|
||||||
import { patchScore } from '../exporter.js';
|
import { patchScore } from '../exporter.js';
|
||||||
import { StatusPoller } from './StatusPoller.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 = {
|
export const PaneCP = {
|
||||||
props: ['store', 'onImportClick'],
|
props: ['store', 'onImportClick', 'onFocusFO'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
const exportError = ref('');
|
const exportError = ref('');
|
||||||
|
const audioWidgetHtml = ref('');
|
||||||
|
|
||||||
function breadcrumbLabel(node) {
|
watch(
|
||||||
if (!node) return '?';
|
() => props.store.synthesisStatus,
|
||||||
if (node.type === 'score') return 'Score';
|
async (s) => {
|
||||||
if (node.type === 'instrument') return node.name;
|
if (!s?.frozen) { audioWidgetHtml.value = ''; return; }
|
||||||
if (node.type === 'variation') return node.dependsOn ? `var(${node.dependsOn})` : 'variation';
|
audioWidgetHtml.value = await fetchAudioWidget(
|
||||||
if (node.type === 'label_spec') return node.label ?? 'label';
|
s.file_accomplished ? URLS.result : null,
|
||||||
if (node.type === 'bar') return node.id;
|
s.errors ?? null,
|
||||||
return node.type;
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
async function doExport() {
|
async function doExport() {
|
||||||
exportError.value = '';
|
exportError.value = '';
|
||||||
@@ -25,10 +59,9 @@ export const PaneCP = {
|
|||||||
try {
|
try {
|
||||||
const raw = await fetchScoreText(props.store.credentials);
|
const raw = await fetchScoreText(props.store.credentials);
|
||||||
props.store.rawScoreText = raw;
|
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);
|
await putScoreText(patched, props.store.credentials);
|
||||||
// Start polling
|
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
||||||
props.store.synthesisStatus = { frozen: false, progress: 0 };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
exportError.value = e.message;
|
exportError.value = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,56 +74,65 @@ export const PaneCP = {
|
|||||||
const model = store.scoreModel;
|
const model = store.scoreModel;
|
||||||
const fp = store.focusPath;
|
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
|
// Header
|
||||||
h('div', { class: 'se-cp-header' }, [
|
h('div', { class: 'se-cp-header' }, [
|
||||||
h('span', { class: 'se-cp-title' },
|
|
||||||
model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'),
|
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'se-btn',
|
class: 'se-btn',
|
||||||
disabled: store.isDirty,
|
disabled: store.isDirty,
|
||||||
title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server',
|
title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server',
|
||||||
onClick: props.onImportClick,
|
onClick: props.onImportClick,
|
||||||
}, 'Import'),
|
}, '→ Import'),
|
||||||
|
h('span', { class: 'se-cp-title' },
|
||||||
|
model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'),
|
||||||
model ? h('button', {
|
model ? h('button', {
|
||||||
class: 'se-btn se-btn-primary',
|
class: 'se-btn se-btn-primary',
|
||||||
disabled: !store.isDirty || exporting.value,
|
disabled: !store.isDirty || exporting.value,
|
||||||
onClick: doExport,
|
onClick: doExport,
|
||||||
}, exporting.value ? 'Exporting…' : 'Export') : null,
|
}, exporting.value ? 'Exporting…' : 'Export ↑') : null,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Breadcrumb
|
// Vertical focus path — short views, clickable to navigate up
|
||||||
fp.length ? h('div', { class: 'se-breadcrumb' }, [
|
pathItems.length ? h('ul', { class: 'se-object-list se-cp-path' },
|
||||||
h('span', { onClick: () => store.setFocus([]) }, 'Score'),
|
pathItems.map(({ node, idx }) => {
|
||||||
...fp.map((node, i) => [
|
const { typeTag, label, meta } = shortView(node);
|
||||||
' › ',
|
const isCurrent = idx === fp.length - 1 || (idx === -1 && fp.length === 0);
|
||||||
h('span', { onClick: () => store.setFocus(fp.slice(0, i + 1)) },
|
return h('li', {
|
||||||
breadcrumbLabel(node)),
|
class: ['se-object-item', isCurrent ? 'focused' : null],
|
||||||
]).flat(),
|
onClick: () => {
|
||||||
]) : null,
|
if (idx === -1) store.setFocus([]);
|
||||||
|
else store.setFocus(fp.slice(0, idx + 1));
|
||||||
// Score info
|
props.onFocusFO?.();
|
||||||
model ? h('dl', { style: 'font-size:0.8rem;margin:0.5rem 0' }, [
|
},
|
||||||
h('dt', null, 'Instruments'),
|
}, [
|
||||||
h('dd', null, String(model.instruments.length)),
|
h('span', { class: 'se-object-type' }, typeTag),
|
||||||
h('dt', null, 'Bars'),
|
h('span', { class: 'se-object-label' }, [
|
||||||
h('dd', null, String(model.bars.length)),
|
h('strong', null, label),
|
||||||
]) : null,
|
...meta.map(({ key, value }) =>
|
||||||
|
h('span', { style: 'color:#888;margin-left:0.5rem;font-size:0.8em' },
|
||||||
|
`${key}=${value}`)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
) : null,
|
||||||
|
|
||||||
// Export error
|
// Export error
|
||||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
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
|
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||||
? h(StatusPoller, { store })
|
? h(StatusPoller, { store }) : null,
|
||||||
: null,
|
|
||||||
|
|
||||||
// Result link
|
// Audio widget (rendered server-side from audiowidget.tmpl)
|
||||||
store.synthesisStatus?.frozen && !store.synthesisStatus?.error
|
audioWidgetHtml.value
|
||||||
? h('a', {
|
? h('div', { innerHTML: audioWidgetHtml.value, style: 'margin-top:0.5rem' })
|
||||||
href: '/sompyle/result.mp3',
|
|
||||||
style: 'display:block;margin-top:0.5rem',
|
|
||||||
}, 'Download result')
|
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { h, ref } from 'vue';
|
import { h, ref } from 'vue';
|
||||||
import { ObjectExtended } from './ObjectExtended.js';
|
import { ObjectExtended } from './ObjectExtended.js';
|
||||||
import { EnvelopeEditor } from './EnvelopeEditor.js';
|
import { EnvelopeEditor } from './EnvelopeEditor.js';
|
||||||
|
import { ShapeEditor } from './ShapeEditor.js';
|
||||||
import { LinkedInstrumentModal } from './LinkedInstrumentModal.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) {
|
function instrFields(instr) {
|
||||||
return [
|
return [
|
||||||
@@ -12,31 +32,35 @@ function instrFields(instr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function variationFields(v) {
|
function variationFields(v) {
|
||||||
return [
|
return [{ key: 'depends_on', value: v.dependsOn ?? '—', editable: true }];
|
||||||
{ 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 = {
|
export const PaneFO = {
|
||||||
props: ['store'],
|
props: ['store'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
// Pending edit held while the linked-instrument modal is shown.
|
const pendingEdit = ref(null); // { instr, undo? }
|
||||||
const pendingEdit = ref(null); // { instr, apply: fn }
|
|
||||||
|
|
||||||
function focused() {
|
function focused() {
|
||||||
const fp = props.store.focusPath;
|
const fp = props.store.focusPath;
|
||||||
return fp.length ? fp[fp.length - 1] : null;
|
return fp.length ? fp[fp.length - 1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a change handler that intercepts the first edit to a linked
|
// Intercepts the first edit to a linked instrument:
|
||||||
// instrument and shows the embed-or-discard modal before applying it.
|
// shows embed-or-discard modal before committing. `info.undo`
|
||||||
function makeChangeHandler(instr, apply) {
|
// (forwarded from ShapeEditor/EnvelopeEditor) reverts the mutation on discard.
|
||||||
return () => {
|
function makeChangeHandler(instr) {
|
||||||
|
return (info) => {
|
||||||
if (instr.isLinked && !instr.isDirty) {
|
if (instr.isLinked && !instr.isDirty) {
|
||||||
// Stash the apply callback and show the modal.
|
pendingEdit.value = { instr, undo: info?.undo };
|
||||||
pendingEdit.value = { instr, apply };
|
|
||||||
} else {
|
} else {
|
||||||
apply();
|
|
||||||
instr.isDirty = true;
|
instr.isDirty = true;
|
||||||
props.store.markDirty();
|
props.store.markDirty();
|
||||||
}
|
}
|
||||||
@@ -47,76 +71,137 @@ export const PaneFO = {
|
|||||||
instr.name = instr.name.split('/').pop();
|
instr.name = instr.name.split('/').pop();
|
||||||
instr.isLinked = false;
|
instr.isLinked = false;
|
||||||
instr.isDirty = true;
|
instr.isDirty = true;
|
||||||
pendingEdit.value.apply();
|
|
||||||
pendingEdit.value = null;
|
pendingEdit.value = null;
|
||||||
props.store.markDirty();
|
props.store.markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardEdit() {
|
function discardEdit() {
|
||||||
|
pendingEdit.value?.undo?.();
|
||||||
pendingEdit.value = null;
|
pendingEdit.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function instrOnChange(instr) {
|
||||||
|
return instr
|
||||||
|
? makeChangeHandler(instr)
|
||||||
|
: () => props.store.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const node = focused();
|
const node = focused();
|
||||||
const children = [];
|
const children = [];
|
||||||
|
|
||||||
if (!node || node.type === 'score') {
|
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') {
|
if (node.type === 'instrument') {
|
||||||
children.push(
|
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 }),
|
h(ObjectExtended, { fields: instrFields(node), onChange: null }),
|
||||||
);
|
);
|
||||||
} else if (node.type === 'variation') {
|
} else if (node.type === 'variation') {
|
||||||
// Find the ancestor instrument for linked-instrument gating.
|
const instr = props.store.scoreModel.instruments.find(
|
||||||
const instr = props.store.scoreModel?.instruments.find(
|
i => i.variations.includes(node) ||
|
||||||
i => i.variations?.includes(node) ||
|
i.variations.some(v => v.subvariations.includes(node))
|
||||||
i.variations?.some(v => v.subvariations?.includes(node))
|
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
const onChange = instrOnChange(instr);
|
||||||
const onChange = instr
|
|
||||||
? makeChangeHandler(instr, () => { props.store.markDirty(); })
|
|
||||||
: () => props.store.markDirty();
|
|
||||||
|
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', { style: 'margin:0 0 0.5rem' }, 'Variation'),
|
h('h4', H4, 'Variation'),
|
||||||
h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
|
h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
|
||||||
if (key === 'depends_on') node.dependsOn = value;
|
if (key === 'depends_on') {
|
||||||
onChange();
|
const old = node.dependsOn;
|
||||||
|
node.dependsOn = value;
|
||||||
|
onChange({ undo: () => { node.dependsOn = old; } });
|
||||||
|
} else {
|
||||||
|
onChange({});
|
||||||
|
}
|
||||||
}}),
|
}}),
|
||||||
node.basicProperties
|
node.basicProperties
|
||||||
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
|
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} else if (node.type === 'label_spec') {
|
} else if (node.type === 'label_spec') {
|
||||||
const instr = props.store.scoreModel?.instruments.find(
|
const instr = props.store.scoreModel.instruments.find(
|
||||||
i => i.variations?.some(v =>
|
i => i.variations.some(v =>
|
||||||
v.labelSpecs?.includes(node) ||
|
v.labelSpecs.includes(node) ||
|
||||||
v.subvariations?.some(sv => sv.labelSpecs?.includes(node))
|
v.subvariations.some(sv => sv.labelSpecs.includes(node))
|
||||||
)
|
)
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
const onChange = instrOnChange(instr);
|
||||||
const onChange = instr
|
|
||||||
? makeChangeHandler(instr, () => { props.store.markDirty(); })
|
|
||||||
: () => props.store.markDirty();
|
|
||||||
|
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Label: ${node.label}`),
|
h('h4', H4, `Label: ${node.label}`),
|
||||||
node.basicProperties
|
node.basicProperties
|
||||||
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
|
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} else if (node.type === 'bar') {
|
} else if (node.type === 'bar') {
|
||||||
|
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };
|
||||||
children.push(
|
children.push(
|
||||||
h('h4', { style: 'margin:0 0 0.5rem' }, `Bar: ${node.id}`),
|
h('h4', H4, `Bar: ${node.id}`),
|
||||||
h(ObjectExtended, { fields: [
|
h(ObjectExtended, {
|
||||||
{ key: 'movement', value: node.movement },
|
fields: [
|
||||||
{ key: 'part', value: node.part },
|
{ key: 'id', value: node.id, editable: false },
|
||||||
{ key: 'line', value: node.line },
|
{ key: 'beats_per_minute', value: node.tempoLevels ?? '', editable: true, type: 'number' },
|
||||||
{ key: 'measure', value: node.measure },
|
{ key: 'stress_pattern', value: stressorToString(node.stressor), editable: true },
|
||||||
], onChange: null }),
|
],
|
||||||
|
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 {
|
} else {
|
||||||
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
|
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import { ObjectShort } from './ObjectShort.js';
|
import { ObjectShort } from './ObjectShort.js';
|
||||||
|
|
||||||
// Shows sub-objects of the currently focused node — variations, bars, voices, etc.
|
|
||||||
export const PaneSubObjects = {
|
export const PaneSubObjects = {
|
||||||
props: ['store'],
|
props: ['store', 'onFocusFO'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
function focused() {
|
function focused() {
|
||||||
const fp = props.store.focusPath;
|
const fp = props.store.focusPath;
|
||||||
@@ -13,31 +12,51 @@ export const PaneSubObjects = {
|
|||||||
function subItems(node) {
|
function subItems(node) {
|
||||||
if (!node) return [];
|
if (!node) return [];
|
||||||
if (node.type === 'score') {
|
if (node.type === 'score') {
|
||||||
return [
|
const items = [];
|
||||||
...node.instruments.map(i => ({ kind: 'instrument', node: i, label: i.name })),
|
if (node.info)
|
||||||
...node.bars.map(b => ({ kind: 'bar', node: b, label: b.id })),
|
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') {
|
if (node.type === 'instrument') {
|
||||||
return node.variations.map((v, idx) => ({
|
return node.variations.map((v, idx) => ({
|
||||||
kind: 'variation',
|
kind: 'variation',
|
||||||
node: v,
|
node: v,
|
||||||
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
||||||
|
hasChildren: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (node.type === 'variation') {
|
if (node.type === 'variation') {
|
||||||
return [
|
return [
|
||||||
...node.labelSpecs.map(ls => ({
|
...node.labelSpecs.map(ls => ({
|
||||||
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)',
|
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false,
|
||||||
})),
|
|
||||||
...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') {
|
if (node.type === 'bar') {
|
||||||
return Object.entries(node.voices).map(([name, v]) => ({
|
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 [];
|
return [];
|
||||||
@@ -46,22 +65,21 @@ export const PaneSubObjects = {
|
|||||||
return () => {
|
return () => {
|
||||||
const node = focused();
|
const node = focused();
|
||||||
const items = subItems(node);
|
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('ul', { class: 'se-object-list' }, items.map((item, idx) =>
|
||||||
h(ObjectShort, {
|
h(ObjectShort, {
|
||||||
key: idx,
|
key: idx,
|
||||||
node: item.node,
|
|
||||||
label: item.label,
|
label: item.label,
|
||||||
typeTag: item.kind,
|
typeTag: item.kind,
|
||||||
focused: props.store.focusPath.includes(item.node),
|
focused: props.store.focusPath.includes(item.node),
|
||||||
hasChildren: item.kind !== 'bar' && item.kind !== 'voice',
|
hasChildren: item.hasChildren,
|
||||||
onFocus: () => props.store.pushFocus(item.node),
|
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||||
onDrillDown: () => props.store.pushFocus(item.node),
|
onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||||
})
|
})
|
||||||
)),
|
))
|
||||||
]);
|
);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
|
||||||
// Renders a shape's coord table with cascade-shift and an SVG preview.
|
// 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 = {
|
export const ShapeEditor = {
|
||||||
props: ['shape', 'onChange'],
|
props: ['shape', 'onChange'],
|
||||||
@@ -14,27 +15,31 @@ export const ShapeEditor = {
|
|||||||
const delta = num - old;
|
const delta = num - old;
|
||||||
coord[field] = num;
|
coord[field] = num;
|
||||||
|
|
||||||
// cascade-shift: if x increased past next coord, shift all following
|
const shifted = [];
|
||||||
if (field === 'x' && delta > 0) {
|
if (field === 'x' && delta > 0) {
|
||||||
for (let j = i + 1; j < props.shape.coords.length; j++) {
|
for (let j = i + 1; j < props.shape.coords.length; j++) {
|
||||||
if (props.shape.coords[j].x <= num) {
|
if (props.shape.coords[j].x <= num) {
|
||||||
|
shifted.push({ j, ox: props.shape.coords[j].x });
|
||||||
props.shape.coords[j].x += delta;
|
props.shape.coords[j].x += delta;
|
||||||
} else break;
|
} else break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
props.onChange?.();
|
props.onChange?.({ undo: () => {
|
||||||
|
coord[field] = old;
|
||||||
|
for (const { j, ox } of shifted) props.shape.coords[j].x = ox;
|
||||||
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCoord() {
|
function addCoord() {
|
||||||
const coords = props.shape.coords;
|
const coords = props.shape.coords;
|
||||||
const lastX = coords.length ? coords[coords.length - 1].x + 1 : 1;
|
const lastX = coords.length ? coords[coords.length - 1].x + 1 : 1;
|
||||||
coords.push({ x: lastX, y: 0, z: 1, isSharp: false });
|
coords.push({ x: lastX, y: 0, z: 1, isSharp: false });
|
||||||
props.onChange?.();
|
props.onChange?.({ undo: () => coords.pop() });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCoord(i) {
|
function removeCoord(i) {
|
||||||
props.shape.coords.splice(i, 1);
|
const removed = props.shape.coords.splice(i, 1)[0];
|
||||||
props.onChange?.();
|
props.onChange?.({ undo: () => props.shape.coords.splice(i, 0, removed) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSvg() {
|
function renderSvg() {
|
||||||
@@ -55,19 +60,14 @@ export const ShapeEditor = {
|
|||||||
return [sx, sy];
|
return [sx, sy];
|
||||||
};
|
};
|
||||||
|
|
||||||
const points = coords.map(c => toSvg(c).join(',') ).join(' ');
|
const points = coords.map(c => toSvg(c).join(',')).join(' ');
|
||||||
|
|
||||||
return h('svg', {
|
return h('svg', {
|
||||||
class: 'se-shape-svg',
|
class: 'se-shape-svg',
|
||||||
viewBox: `0 0 ${W} ${H}`,
|
viewBox: `0 0 ${W} ${H}`,
|
||||||
preserveAspectRatio: 'none',
|
preserveAspectRatio: 'none',
|
||||||
}, [
|
}, [
|
||||||
h('polyline', {
|
h('polyline', { points, fill: 'none', stroke: '#2a6aaa', 'stroke-width': '1.5' }),
|
||||||
points,
|
|
||||||
fill: 'none',
|
|
||||||
stroke: '#2a6aaa',
|
|
||||||
'stroke-width': '1.5',
|
|
||||||
}),
|
|
||||||
...coords.map(c => {
|
...coords.map(c => {
|
||||||
const [sx, sy] = toSvg(c);
|
const [sx, sy] = toSvg(c);
|
||||||
return h('circle', { cx: sx, cy: sy, r: 2.5, fill: '#6aacff' });
|
return h('circle', { cx: sx, cy: sy, r: 2.5, fill: '#6aacff' });
|
||||||
@@ -101,7 +101,11 @@ export const ShapeEditor = {
|
|||||||
})),
|
})),
|
||||||
h('td', null, h('input', {
|
h('td', null, h('input', {
|
||||||
type: 'checkbox', checked: !!coord.isSharp,
|
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) }, '✕')),
|
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';
|
import { fetchStatus } from '../api.js';
|
||||||
|
|
||||||
export const StatusPoller = {
|
export const StatusPoller = {
|
||||||
props: ['store'],
|
props: ['store'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const timer = ref(null);
|
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() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const status = await fetchStatus(props.store.credentials);
|
const status = await fetchStatus(props.store.credentials);
|
||||||
if (status.eta) eta.value = status.eta;
|
|
||||||
props.store.synthesisStatus = status;
|
props.store.synthesisStatus = status;
|
||||||
if (status.frozen) {
|
if (status.frozen) return;
|
||||||
// done — stop polling
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// transient error — keep polling
|
// transient — keep polling
|
||||||
}
|
}
|
||||||
timer.value = setTimeout(poll, nextInterval(props.store.synthesisStatus));
|
timer.value = setTimeout(poll, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { poll(); });
|
onMounted(() => { poll(); });
|
||||||
@@ -39,10 +24,16 @@ export const StatusPoller = {
|
|||||||
const s = props.store.synthesisStatus;
|
const s = props.store.synthesisStatus;
|
||||||
if (!s) return h('div', { class: 'se-status' }, 'Polling…');
|
if (!s) return h('div', { class: 'se-status' }, 'Polling…');
|
||||||
|
|
||||||
const pct = s.progress ?? 0;
|
const total = s.notes_in_total ?? 0;
|
||||||
const label = s.frozen
|
const done = s.currently_rendered_notes ?? 0;
|
||||||
? (s.error ? `Error: ${s.error}` : 'Done')
|
const pct = total > 0 ? Math.min(100, Math.round(done / total * 100)) : 0;
|
||||||
: `${s.state ?? 'Running'} ${pct}%`;
|
|
||||||
|
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' }, [
|
return h('div', { class: 'se-status' }, [
|
||||||
h('span', null, label),
|
h('span', null, label),
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ function serializeShape(shape) {
|
|||||||
// ── FM / AM modulation ─────────────────────────────────────────────────────
|
// ── FM / AM modulation ─────────────────────────────────────────────────────
|
||||||
// RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
|
// RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
|
||||||
|
|
||||||
function serializeFm(fm) {
|
function serializeModulation(m) {
|
||||||
let s = String(fm.frequency ?? '');
|
let s = String(m.frequency ?? '');
|
||||||
if (fm.factor) s += fm.factor;
|
if (m.oscillator) s += `@${m.oscillator}`;
|
||||||
if (fm.osc) s += `@${fm.osc}`;
|
if (m.shape) s += `[${serializeShape(m.shape)}]`;
|
||||||
if (fm.shape) s += `[${serializeShape(fm.shape)}]`;
|
s += `;${m.mod_share ?? ''}:${m.base_share ?? ''}`;
|
||||||
s += `;${fm.mod ?? ''}:${fm.base ?? ''}`;
|
if (m.init_phase != null) s += m.init_phase >= 0 ? `+${m.init_phase}` : String(m.init_phase);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,8 @@ function basicPropLines(bp) {
|
|||||||
if (s) lines.push(`S: "${s}"`);
|
if (s) lines.push(`S: "${s}"`);
|
||||||
const r = serializeShape(bp.R);
|
const r = serializeShape(bp.R);
|
||||||
if (r) lines.push(`R: "${r}"`);
|
if (r) lines.push(`R: "${r}"`);
|
||||||
for (const fm of (bp.fmModulations ?? [])) lines.push(`FM: "${serializeFm(fm)}"`);
|
for (const fm of (bp.fmModulations ?? [])) lines.push(`FM: "${serializeModulation(fm)}"`);
|
||||||
|
for (const am of (bp.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ function labelSpecLines(ls) {
|
|||||||
|
|
||||||
// ── Variation ─────────────────────────────────────────────────────────────
|
// ── Variation ─────────────────────────────────────────────────────────────
|
||||||
// Returns YAML lines for one variation MAPPING (no leading "- ").
|
// Returns YAML lines for one variation MAPPING (no leading "- ").
|
||||||
|
// RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level.
|
||||||
|
|
||||||
function variationLines(v) {
|
function variationLines(v) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@@ -70,25 +72,37 @@ function variationLines(v) {
|
|||||||
for (const ls of (v.labelSpecs ?? [])) lines.push(...labelSpecLines(ls));
|
for (const ls of (v.labelSpecs ?? [])) lines.push(...labelSpecLines(ls));
|
||||||
if (v.spread?.length) lines.push(`SPREAD: [${v.spread.join(', ')}]`);
|
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}"`); }
|
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));
|
for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv));
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Instrument character block ─────────────────────────────────────────────
|
// ── Instrument character block ─────────────────────────────────────────────
|
||||||
// VOLUMES / TIMBRE / FM appear at depth 01 (direct instrument children per RFC §3.2.1.3).
|
// VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser
|
||||||
// RAILSBACK_CURVE is depth 02 (inside variation) and is emitted by variationLines().
|
// 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) {
|
function instrCharacterLines(instr) {
|
||||||
const extraLines = [];
|
|
||||||
const vol = serializeShape(instr.volumes);
|
|
||||||
if (vol) extraLines.push(`VOLUMES: "${vol}"`);
|
|
||||||
const timbre = serializeShape(instr.timbre);
|
|
||||||
if (timbre) extraLines.push(`TIMBRE: "${timbre}"`);
|
|
||||||
for (const fm of (instr.fmModulations ?? [])) extraLines.push(`FM: "${serializeFm(fm)}"`);
|
|
||||||
|
|
||||||
const variations = instr.variations ?? [];
|
const variations = instr.variations ?? [];
|
||||||
const syntheticRoot = instr.basicProperties
|
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
|
||||||
? { basicProperties: instr.basicProperties, labelSpecs: [], subvariations: [], spread: null, dependsOn: null }
|
(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;
|
: null;
|
||||||
|
|
||||||
const allVariations = [
|
const allVariations = [
|
||||||
@@ -97,21 +111,17 @@ function instrCharacterLines(instr) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (allVariations.length <= 1) {
|
if (allVariations.length <= 1) {
|
||||||
// Single variation — emit as MAPPING directly under character:
|
const vLines = allVariations.length ? variationLines(allVariations[0]) : [];
|
||||||
const vLines = allVariations.length
|
|
||||||
? [...variationLines(allVariations[0]), ...extraLines]
|
|
||||||
: extraLines;
|
|
||||||
return vLines.map(l => ` ${l}`);
|
return vLines.map(l => ` ${l}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
|
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
|
||||||
const result = [];
|
const result = [];
|
||||||
for (let i = 0; i < allVariations.length; i++) {
|
for (const v of allVariations) {
|
||||||
const vLines = variationLines(allVariations[i]);
|
const vLines = variationLines(v);
|
||||||
const allLines = i === 0 ? [...vLines, ...extraLines] : vLines;
|
if (!vLines.length) continue;
|
||||||
if (!allLines.length) continue;
|
result.push(` - ${vLines[0]}`);
|
||||||
result.push(` - ${allLines[0]}`);
|
for (const l of vLines.slice(1)) result.push(` ${l}`);
|
||||||
for (const l of allLines.slice(1)) result.push(` ${l}`);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -128,11 +138,36 @@ export function exportInstrument(instr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Score patch ────────────────────────────────────────────────────────────
|
// ── Score patch ────────────────────────────────────────────────────────────
|
||||||
// Replace dirty instrument blocks in rawScoreText with RFC-serialized output.
|
// Replace dirty instrument blocks and dirty bar _meta blocks.
|
||||||
// Non-dirty instruments are left verbatim.
|
// Voice note content in bar documents is left verbatim.
|
||||||
|
|
||||||
export function patchScore(rawScoreText, instruments) {
|
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 result = [];
|
||||||
const instrMap = {};
|
const instrMap = {};
|
||||||
for (const instr of instruments) {
|
for (const instr of instruments) {
|
||||||
@@ -143,7 +178,6 @@ export function patchScore(rawScoreText, instruments) {
|
|||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
// RFC §4.4: "instrument NAME:" — strip optional quotes around name
|
|
||||||
const m = line.match(/^instrument\s+(.+?)\s*:/);
|
const m = line.match(/^instrument\s+(.+?)\s*:/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const rawName = m[1].replace(/^'|'$/g, '');
|
const rawName = m[1].replace(/^'|'$/g, '');
|
||||||
@@ -165,3 +199,67 @@ export function patchScore(rawScoreText, instruments) {
|
|||||||
|
|
||||||
return result.join('\n');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
18
templates/vue3_neusik/audiowidget.tmpl.stub
Normal file
18
templates/vue3_neusik/audiowidget.tmpl.stub
Normal 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 %}
|
||||||
@@ -9,6 +9,16 @@
|
|||||||
<div id="score-editor-app"
|
<div id="score-editor-app"
|
||||||
data-import-on-load="{{ import_on_load }}">
|
data-import-on-load="{{ import_on_load }}">
|
||||||
</div>
|
</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">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
199
test-parser.mjs
199
test-parser.mjs
@@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Fixture-based compliance test for ast-parser.js + exporter.js
|
// Fixture-based compliance test for ast-parser.js + exporter.js
|
||||||
// Run: node score_editors/vue3_neusik/test-parser.mjs
|
// Run: node test-parser.mjs
|
||||||
|
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { parseAstLog, buildModel } from './static/ast-parser.js';
|
import { parseAstLog, buildModel } from './static/ast-parser.js';
|
||||||
import { exportInstrument, patchScore } from './static/exporter.js';
|
import { exportInstrument, patchScore, stressorToString } from './static/exporter.js';
|
||||||
|
|
||||||
const FIXTURE = new URL(
|
const FIXTURE = new URL('./fixtures/ast.log', import.meta.url);
|
||||||
'../../PLAN/vue3js-app-proposal-for-sdk-claude/fixtures/ast.log',
|
|
||||||
import.meta.url
|
|
||||||
);
|
|
||||||
const text = readFileSync(FIXTURE, 'utf8');
|
const text = readFileSync(FIXTURE, 'utf8');
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
let pass = 0, fail = 0;
|
||||||
@@ -191,10 +188,7 @@ if (alpha) {
|
|||||||
|
|
||||||
// ── patchScore ─────────────────────────────────────────────────────────────
|
// ── patchScore ─────────────────────────────────────────────────────────────
|
||||||
section('patchScore');
|
section('patchScore');
|
||||||
const SCORE_FIXTURE = new URL(
|
const SCORE_FIXTURE = new URL('./fixtures/pathetique.spls', import.meta.url);
|
||||||
'../../PLAN/vue3js-app-proposal-for-sdk-claude/fixtures/pathetique.spls',
|
|
||||||
import.meta.url
|
|
||||||
);
|
|
||||||
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
|
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
|
||||||
|
|
||||||
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
|
||||||
@@ -249,6 +243,191 @@ ok('FM shape start', fm?.shape?.start === '6' || fm?.shape?.start === 6);
|
|||||||
fmInstr.isDirty = true;
|
fmInstr.isDirty = true;
|
||||||
const fmOut = exportInstrument(fmInstr);
|
const fmOut = exportInstrument(fmInstr);
|
||||||
ok('FM exported with [shape]', /FM:.*\[.*\]/.test(fmOut));
|
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 ────────────────────────────────────────────────────────────────
|
// ── Summary ────────────────────────────────────────────────────────────────
|
||||||
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
||||||
|
|||||||
Reference in New Issue
Block a user