forked from flow/vue3js-app-proposal-for-sdk-claude
Compare commits
59 Commits
822e2c9f42
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c89711be | ||
|
|
f18f07dc22 | ||
|
|
d8e6e7a02c | ||
|
|
d79599ede1 | ||
|
|
7ffa0a9d2d | ||
|
|
e2b7af77d6 | ||
|
|
fc31c40fc3 | ||
|
|
e908bd4768 | ||
|
|
a0104214b7 | ||
|
|
bf35441b77 | ||
|
|
d3a55cb663 | ||
|
|
884955195a | ||
|
|
3beaa1d2ec | ||
|
|
7fb584d929 | ||
|
|
e823608325 | ||
|
|
9a2e76ba4f | ||
|
|
1ad4b73c9a | ||
|
|
41b17d4bb0 | ||
|
|
66c1f2c151 | ||
|
|
e011c9e7d8 | ||
|
|
89f46e7579 | ||
|
|
67c75781d8 | ||
|
|
86b14dfaa3 | ||
|
|
5bec1a1d1e | ||
|
|
b822e4d919 | ||
|
|
a91b5747a3 | ||
|
|
133117922a | ||
|
|
913114bb02 | ||
|
|
b23e243225 | ||
|
|
330b3788f0 | ||
|
|
6be4a3297a | ||
|
|
b56d84befb | ||
|
|
859e62e143 | ||
|
|
7fd29ef9b4 | ||
|
|
b887341df4 | ||
|
|
94758243f3 | ||
|
|
3cd66f1e10 | ||
|
|
b745c3e6ad | ||
|
|
be338c2de0 | ||
|
|
06c077b7f8 | ||
|
|
c7c246c32d | ||
|
|
c4804f0b55 | ||
|
|
097d4488ef | ||
|
|
720a0b6b25 | ||
|
|
89198ec37e | ||
|
|
e4b5cf6887 | ||
|
|
923aa5d41a | ||
|
|
1706404b7e | ||
|
|
9eb4add695 | ||
|
|
338ea5be49 | ||
|
|
c52b0bf9cf | ||
|
|
4c392927cf | ||
|
|
2060f109b6 | ||
|
|
06b2bab5d3 | ||
|
|
839ea3f95c | ||
|
|
4afabea837 | ||
|
|
f318b155cc | ||
|
|
17f4529658 | ||
|
|
c8b8d03e3f |
12
__init__.py
12
__init__.py
@@ -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
|
||||
|
||||
18151
fixtures/ast.log
18151
fixtures/ast.log
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,40 @@
|
||||
function authHeader(credentials) {
|
||||
if (!credentials) return {};
|
||||
const b64 = btoa(`${credentials.username}:${credentials.password}`);
|
||||
return { Authorization: `Basic ${b64}` };
|
||||
}
|
||||
const U = window.NEUSICIAN_URLS;
|
||||
|
||||
export async function fetchAstLog(credentials) {
|
||||
const res = await fetch('/sompyle/astlog', {
|
||||
headers: authHeader(credentials),
|
||||
});
|
||||
export const URLS = U;
|
||||
|
||||
export async function fetchAstLog() {
|
||||
const res = await fetch(U.astlog);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchScoreText(credentials) {
|
||||
const res = await fetch('/sompyle/score.spls', {
|
||||
headers: authHeader(credentials),
|
||||
});
|
||||
export async function fetchScoreText() {
|
||||
const res = await fetch(U.score);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function putScoreText(text, credentials) {
|
||||
const res = await fetch('/sompyle/score.spls', {
|
||||
export async function putScoreText(text) {
|
||||
const res = await fetch(U.score, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...authHeader(credentials),
|
||||
'Content-Type': 'text/yaml',
|
||||
},
|
||||
headers: { 'Content-Type': 'text/yaml' },
|
||||
body: text,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function fetchStatus(credentials) {
|
||||
const res = await fetch('/sompyle/status.json', {
|
||||
headers: authHeader(credentials),
|
||||
});
|
||||
export async function fetchStatus() {
|
||||
const res = await fetch(U.status);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { coerce } from './util.js';
|
||||
|
||||
const LINE_RE = /^(\d{2}) (\S+(?:\.\S+)*) ?(.*)/;
|
||||
const DEBUG_RE = /^\d{2} # DEBUG/;
|
||||
|
||||
|
||||
function coerce(s) {
|
||||
if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true;
|
||||
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;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function parseAstLog(text) {
|
||||
const root = { slot: 'root', parentSlot: null, depth: -1, positionals: [], props: {}, children: [] };
|
||||
const stack = [root];
|
||||
@@ -33,15 +25,19 @@ export function parseAstLog(text) {
|
||||
slot = slotFull.slice(dotIdx + 1);
|
||||
}
|
||||
|
||||
const { positionals, props } = parseRest(rest);
|
||||
const { positionals, props } = _parseRest(rest);
|
||||
|
||||
const node = { slot, parentSlot, depth, positionals, props, children: [] };
|
||||
|
||||
// Pop stack to find correct parent (parent must have depth < current)
|
||||
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
if (depth > stack[stack.length - 1].depth + 1)
|
||||
throw new Error(`AST log malformed: depth jump to ${depth} at: ${line}`);
|
||||
if (depth > 0 && !slotFull.includes('.'))
|
||||
throw new Error(`AST log malformed: missing slot at depth ${depth}: ${line}`);
|
||||
|
||||
stack[stack.length - 1].children.push(node);
|
||||
stack.push(node);
|
||||
}
|
||||
@@ -49,59 +45,25 @@ export function parseAstLog(text) {
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseRest(rest) {
|
||||
const _REST_TOKEN = /(\w+=)?('[^']*'|\S+)/g;
|
||||
|
||||
function _parseRest(rest) {
|
||||
const positionals = [];
|
||||
const props = {};
|
||||
let i = 0;
|
||||
const n = rest.length;
|
||||
let inProps = false;
|
||||
|
||||
while (i < n) {
|
||||
// skip spaces
|
||||
while (i < n && rest[i] === ' ') i++;
|
||||
if (i >= n) break;
|
||||
|
||||
if (rest[i] === "'") {
|
||||
// single-quoted value (positional string)
|
||||
const j = rest.indexOf("'", i + 1);
|
||||
const val = rest.slice(i + 1, j === -1 ? n : j);
|
||||
i = j === -1 ? n : j + 1;
|
||||
if (!inProps) positionals.push(val);
|
||||
} else {
|
||||
// scan to next space
|
||||
let j = i;
|
||||
while (j < n && rest[j] !== ' ') j++;
|
||||
const tok = rest.slice(i, j);
|
||||
i = j;
|
||||
|
||||
const eqIdx = tok.indexOf('=');
|
||||
if (eqIdx !== -1) {
|
||||
inProps = true;
|
||||
const key = tok.slice(0, eqIdx);
|
||||
let rawVal = tok.slice(eqIdx + 1);
|
||||
|
||||
if (rawVal.startsWith("'")) {
|
||||
// value continues until next single quote
|
||||
const valStart = i - (tok.length - eqIdx - 1);
|
||||
// find closing quote: search from after the opening quote
|
||||
const openPos = rest.indexOf("'", rest.lastIndexOf(key + '=', i) + key.length + 1);
|
||||
const closePos = rest.indexOf("'", openPos + 1);
|
||||
rawVal = closePos === -1 ? rest.slice(openPos + 1) : rest.slice(openPos + 1, closePos);
|
||||
i = closePos === -1 ? n : closePos + 1;
|
||||
}
|
||||
|
||||
props[key] = coerce(rawVal);
|
||||
} else if (!inProps) {
|
||||
positionals.push(coerce(tok));
|
||||
}
|
||||
// bare token after props start is ignored (shouldn't happen per spec)
|
||||
}
|
||||
for (const [, keyEq, raw] of rest.matchAll(_REST_TOKEN)) {
|
||||
const val = coerce(raw.startsWith("'") ? raw.slice(1, -1) : raw);
|
||||
if (keyEq !== undefined) { inProps = true; props[keyEq.slice(0, -1)] = val; }
|
||||
else if (!inProps) { positionals.push(val); }
|
||||
}
|
||||
|
||||
return { positionals, props };
|
||||
}
|
||||
|
||||
// ── Second pass: build typed model ──────────────────────────────────────────
|
||||
function _collectUnknownProps(nodeProps, knownKeys) {
|
||||
return Object.fromEntries(Object.entries(nodeProps).filter(([k]) => !knownKeys.has(k)));
|
||||
}
|
||||
|
||||
export function buildModel(rawTree) {
|
||||
const score = {
|
||||
@@ -109,198 +71,264 @@ export function buildModel(rawTree) {
|
||||
info: null,
|
||||
tuning: null,
|
||||
articles: [],
|
||||
stageCone: null,
|
||||
stageVoices: [],
|
||||
instruments: [],
|
||||
bars: [],
|
||||
};
|
||||
|
||||
// Upstream uses `with deeper_level("articles"):` / `deeper_level("stage"):`
|
||||
// implicit containers that emit no depth-00 header. Their depth-01 lines get
|
||||
// nested under the preceding `00 tuning` by the depth-stack parser even
|
||||
// though they are conceptually siblings of tuning. Un-nest them here.
|
||||
const topLevel = [];
|
||||
for (const node of rawTree.children) {
|
||||
switch (node.slot) {
|
||||
if (node.parentSlot === null && node.slot === 'tuning') {
|
||||
const trulyTuning = [];
|
||||
const misnested = [];
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'tuning') trulyTuning.push(child);
|
||||
else misnested.push(child);
|
||||
}
|
||||
topLevel.push({ ...node, children: trulyTuning });
|
||||
topLevel.push(...misnested);
|
||||
} else {
|
||||
topLevel.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of topLevel) {
|
||||
const fqSlot = node.parentSlot ? `${node.parentSlot}.${node.slot}` : node.slot;
|
||||
switch (fqSlot) {
|
||||
case 'info':
|
||||
score.info = { ...node.props };
|
||||
break;
|
||||
case 'tuning':
|
||||
score.tuning = buildTuning(node);
|
||||
score.tuning = _buildTuning(node);
|
||||
break;
|
||||
case 'article':
|
||||
score.articles.push(buildArticle(node));
|
||||
case 'stage.cone':
|
||||
score.stageCone = { type: 'stage_cone', ...node.props };
|
||||
break;
|
||||
case 'stage_voice':
|
||||
score.stageVoices.push({
|
||||
case 'stage.article':
|
||||
score.articles.push(_buildArticleEntry(node));
|
||||
break;
|
||||
case 'stage.voice': {
|
||||
const sv = {
|
||||
type: 'stage_voice',
|
||||
name: node.positionals[0],
|
||||
direction: node.props.direction,
|
||||
distance: node.props.distance,
|
||||
});
|
||||
articles: [],
|
||||
};
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'voice' && child.slot === 'article')
|
||||
sv.articles.push(_buildArticleEntry(child));
|
||||
}
|
||||
score.stageVoices.push(sv);
|
||||
break;
|
||||
}
|
||||
case 'instrument':
|
||||
score.instruments.push(buildInstrument(node));
|
||||
score.instruments.push(_buildInstrument(node));
|
||||
break;
|
||||
case 'bar':
|
||||
score.bars.push(buildBar(node));
|
||||
score.bars.push(_buildBar(node));
|
||||
break;
|
||||
default:
|
||||
score[node.slot] = buildGeneric(node);
|
||||
score[node.slot] = _buildGeneric(node);
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function buildTuning(node) {
|
||||
const t = { base: node.props.base, scales: {}, chords: {} };
|
||||
function _buildArticleEntry(node) {
|
||||
const entry = { type: 'article', name: node.positionals[0], properties: [] };
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot !== 'article') continue;
|
||||
if (child.slot === 'defaults' || child.slot === 'definite') {
|
||||
const raw = child.props.constant ?? child.props.stacked ?? child.props.static;
|
||||
const shapeChild = child.children.find(c => c.slot === 'shape');
|
||||
const value = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null);
|
||||
entry.properties.push({ name: child.positionals[0], value, scope: child.slot, overwritten: child.props.overwritten ?? false });
|
||||
} else if (child.slot === 'overwrites') {
|
||||
for (const propName of child.positionals) {
|
||||
entry.properties.push({ name: propName, value: null, scope: 'overwrites' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function _buildTuning(node) {
|
||||
const t = { type: 'tuning', base: node.props.base, scales: {}, chords: {}, frequencyFactors: null };
|
||||
for (const child of node.children) {
|
||||
if (child.slot === 'scales') {
|
||||
t.scales[child.positionals[0]] = child.positionals.slice(1);
|
||||
} else if (child.slot === 'chords') {
|
||||
t.chords[child.positionals[0]] = child.positionals.slice(1);
|
||||
} else if (child.slot === 'frequency_factors') {
|
||||
t.frequencyFactors = {
|
||||
label: child.props.label ?? null,
|
||||
factors: child.positionals.slice(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function buildArticle(node) {
|
||||
return {
|
||||
type: 'article',
|
||||
name: node.positionals[0],
|
||||
props: { ...node.props },
|
||||
properties: node.children
|
||||
.filter(c => c.slot === 'property')
|
||||
.map(c => ({ name: c.positionals[0], ...c.props })),
|
||||
};
|
||||
}
|
||||
|
||||
function buildInstrument(node) {
|
||||
function _buildInstrument(node) {
|
||||
const instr = {
|
||||
type: 'instrument',
|
||||
name: node.positionals[0],
|
||||
notChangedSince: node.props.NOT_CHANGED_SINCE ?? null,
|
||||
isLinked: (node.positionals[0] ?? '').includes('/'),
|
||||
isDirty: false,
|
||||
variations: [],
|
||||
basicProperties: null,
|
||||
railsbackCurve: null,
|
||||
volumes: null,
|
||||
timbre: null,
|
||||
fmModulations: [],
|
||||
rawChildren: [],
|
||||
amModulations: [],
|
||||
unknownSlots: [],
|
||||
};
|
||||
|
||||
for (const child of node.children) {
|
||||
switch (child.parentSlot + '.' + child.slot) {
|
||||
case 'character.variation':
|
||||
instr.variations.push(buildVariation(child));
|
||||
instr.variations.push(_buildVariation(child));
|
||||
break;
|
||||
case 'character.basic_properties':
|
||||
instr.basicProperties = buildBasicProperties(child);
|
||||
break;
|
||||
case 'RAILSBACK_CURVE.shape':
|
||||
instr.railsbackCurve = buildShape(child);
|
||||
instr.basicProperties = _buildBasicProperties(child);
|
||||
break;
|
||||
case 'VOLUMES.shape':
|
||||
instr.volumes = buildShape(child);
|
||||
instr.volumes = _buildShape(child);
|
||||
break;
|
||||
case 'TIMBRE.shape':
|
||||
instr.timbre = buildShape(child);
|
||||
instr.timbre = _buildShape(child);
|
||||
break;
|
||||
case 'FM.modulation':
|
||||
instr.fmModulations.push({ ...child.props });
|
||||
break;
|
||||
case 'AM.modulation':
|
||||
instr.amModulations.push({ ...child.props });
|
||||
break;
|
||||
default:
|
||||
instr.rawChildren.push(buildGeneric(child));
|
||||
instr.unknownSlots.push(_buildGeneric(child));
|
||||
}
|
||||
}
|
||||
|
||||
return instr;
|
||||
}
|
||||
|
||||
function buildVariation(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,
|
||||
rawChildren: [],
|
||||
railsbackCurve: null,
|
||||
unknownSlots: [],
|
||||
};
|
||||
|
||||
for (const child of node.children) {
|
||||
const key = (child.parentSlot ?? child.slot) + '.' + child.slot;
|
||||
switch (key) {
|
||||
case 'variation.basic_properties':
|
||||
v.basicProperties = buildBasicProperties(child);
|
||||
v.basicProperties = _buildBasicProperties(child);
|
||||
break;
|
||||
case 'variation.label_spec':
|
||||
v.labelSpecs.push(buildLabelSpec(child));
|
||||
v.labelSpecs.push(_buildLabelSpec(child));
|
||||
break;
|
||||
case 'variation.subvariation':
|
||||
v.subvariations.push(buildVariation(child));
|
||||
v.subvariations.push(_buildVariation(child));
|
||||
break;
|
||||
case 'variation.SPREAD':
|
||||
v.spread = child.positionals;
|
||||
break;
|
||||
case 'RAILSBACK_CURVE.shape':
|
||||
v.railsbackCurve = _buildShape(child);
|
||||
break;
|
||||
default:
|
||||
v.rawChildren.push(buildGeneric(child));
|
||||
v.unknownSlots.push(_buildGeneric(child));
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
function buildBasicProperties(node) {
|
||||
function _buildBasicProperties(node) {
|
||||
const bp = {
|
||||
type: 'basic_properties',
|
||||
A: null, S: null, R: null,
|
||||
oscillator: null,
|
||||
fmModulations: [],
|
||||
rawChildren: [],
|
||||
amModulations: [],
|
||||
unknownSlots: [],
|
||||
};
|
||||
|
||||
for (const child of node.children) {
|
||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||
if (child.parentSlot === 'A' && child.slot === 'shape') {
|
||||
bp.A = buildShape(child);
|
||||
bp.A = _buildShape(child);
|
||||
} else if (child.parentSlot === 'S' && child.slot === 'shape') {
|
||||
bp.S = buildShape(child);
|
||||
bp.S = _buildShape(child);
|
||||
} else if (child.parentSlot === 'R' && child.slot === 'shape') {
|
||||
bp.R = buildShape(child);
|
||||
bp.R = _buildShape(child);
|
||||
} 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));
|
||||
bp.unknownSlots.push(_buildGeneric(child));
|
||||
}
|
||||
}
|
||||
|
||||
return bp;
|
||||
}
|
||||
|
||||
function buildLabelSpec(node) {
|
||||
function _buildLabelSpec(node) {
|
||||
const ls = {
|
||||
type: 'label_spec',
|
||||
label: node.positionals[0],
|
||||
basicProperties: null,
|
||||
rawChildren: [],
|
||||
unknownSlots: [],
|
||||
};
|
||||
|
||||
const directBpChildren = [];
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'variation' && child.slot === 'basic_properties') {
|
||||
ls.basicProperties = buildBasicProperties(child);
|
||||
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));
|
||||
ls.unknownSlots.push(_buildGeneric(child));
|
||||
}
|
||||
}
|
||||
if (!ls.basicProperties && directBpChildren.length > 0) {
|
||||
ls.basicProperties = _buildBasicProperties({ children: directBpChildren });
|
||||
}
|
||||
|
||||
return ls;
|
||||
}
|
||||
|
||||
function buildShape(node) {
|
||||
function _buildShape(node) {
|
||||
return {
|
||||
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 => ({
|
||||
@@ -312,16 +340,10 @@ function buildShape(node) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildBar(node) {
|
||||
const id = node.positionals[0] ?? '';
|
||||
const idMatch = id.match(/^(\w?)(\d+)P(\d+)L(\d+)M(\d+)$/);
|
||||
function _buildBar(node) {
|
||||
const bar = {
|
||||
type: 'bar',
|
||||
id,
|
||||
movement: idMatch ? idMatch[1] : '',
|
||||
part: idMatch ? parseInt(idMatch[2]) : 0,
|
||||
line: idMatch ? parseInt(idMatch[3]) : 0,
|
||||
measure: idMatch ? parseInt(idMatch[4]) : 0,
|
||||
id: node.positionals[0] ?? '',
|
||||
stressor: null,
|
||||
tempoShape: null,
|
||||
tempoLevels: null,
|
||||
@@ -329,42 +351,42 @@ function buildBar(node) {
|
||||
upperStressBound: null,
|
||||
tunings: [],
|
||||
voices: {},
|
||||
rawChildren: [],
|
||||
unknownSlots: [],
|
||||
};
|
||||
|
||||
for (const child of node.children) {
|
||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||
switch (fqSlot) {
|
||||
case 'stress_pattern.stressor':
|
||||
bar.stressor = buildStressor(child);
|
||||
bar.stressor = _buildStressor(child);
|
||||
break;
|
||||
case 'tempo.shape':
|
||||
bar.tempoShape = buildShape(child);
|
||||
bar.tempoShape = _buildShape(child);
|
||||
break;
|
||||
case 'tempo.levels':
|
||||
bar.tempoLevels = child.positionals[0];
|
||||
break;
|
||||
case 'lower_stress_bound.shape':
|
||||
bar.lowerStressBound = buildShape(child);
|
||||
bar.lowerStressBound = _buildShape(child);
|
||||
break;
|
||||
case 'upper_stress_bound.shape':
|
||||
bar.upperStressBound = buildShape(child);
|
||||
bar.upperStressBound = _buildShape(child);
|
||||
break;
|
||||
case 'bar.tuning':
|
||||
bar.tunings.push({ ...child.props });
|
||||
break;
|
||||
case 'bar.voice':
|
||||
bar.voices[child.positionals[0]] = buildVoice(child);
|
||||
bar.voices[child.positionals[0]] = _buildVoice(child);
|
||||
break;
|
||||
default:
|
||||
bar.rawChildren.push(buildGeneric(child));
|
||||
bar.unknownSlots.push(_buildGeneric(child));
|
||||
}
|
||||
}
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
function buildStressor(node) {
|
||||
function _buildStressor(node) {
|
||||
const levels = [];
|
||||
let currentGroup = [];
|
||||
for (const child of node.children) {
|
||||
@@ -379,7 +401,7 @@ function buildStressor(node) {
|
||||
return { type: 'stressor', groups: levels };
|
||||
}
|
||||
|
||||
function buildVoice(node) {
|
||||
function _buildVoice(node) {
|
||||
const voice = {
|
||||
type: 'voice',
|
||||
name: node.positionals[0],
|
||||
@@ -392,13 +414,13 @@ function buildVoice(node) {
|
||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||
switch (fqSlot) {
|
||||
case 'voice.offset':
|
||||
voice.offsets.push(buildOffset(child));
|
||||
voice.offsets.push(_buildOffset(child));
|
||||
break;
|
||||
case 'voice.article':
|
||||
voice.articles.push(child.positionals[0]);
|
||||
voice.articles.push(_buildArticleEntry(child));
|
||||
break;
|
||||
case 'voice.motif':
|
||||
voice.motifs.push(child.props.label);
|
||||
voice.motifs.push(_buildMotif(child));
|
||||
break;
|
||||
default:
|
||||
// ignore
|
||||
@@ -408,75 +430,89 @@ function buildVoice(node) {
|
||||
return voice;
|
||||
}
|
||||
|
||||
function buildOffset(node) {
|
||||
function _buildOffset(node) {
|
||||
const offset = {
|
||||
type: 'offset',
|
||||
tick: node.props.tick,
|
||||
stemNotes: [],
|
||||
clusters: [],
|
||||
chains: [],
|
||||
motifRefs: [],
|
||||
unknownProps: _collectUnknownProps(node.props, new Set(['tick'])),
|
||||
};
|
||||
|
||||
for (const child of node.children) {
|
||||
const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot;
|
||||
switch (fqSlot) {
|
||||
case 'offset.stem_note':
|
||||
offset.stemNotes.push({ pitch: child.props.pitch, effLength: child.props.eff_length });
|
||||
break;
|
||||
case 'offset.cluster':
|
||||
offset.clusters.push(buildCluster(child));
|
||||
break;
|
||||
case 'offset.chain':
|
||||
offset.chains.push({ index: child.positionals[0], children: child.children.map(buildGeneric) });
|
||||
break;
|
||||
default:
|
||||
// ignore
|
||||
}
|
||||
if (child.parentSlot === 'line' && child.slot === 'stem_note')
|
||||
offset.stemNotes.push(_buildStemNote(child));
|
||||
else if (child.parentSlot === 'line' && child.slot === 'motif')
|
||||
offset.motifRefs.push({ label: child.positionals[0], chord: child.props.chord ?? null });
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
function buildCluster(node) {
|
||||
const cluster = {
|
||||
type: 'cluster',
|
||||
index: node.positionals[0],
|
||||
repeat: node.props.repeat ?? null,
|
||||
notes: [],
|
||||
pauses: [],
|
||||
groups: [],
|
||||
subchains: [],
|
||||
};
|
||||
|
||||
function _buildStemNote(node) {
|
||||
const KNOWN = new Set(['pitch', 'eff_length', 'adj_stress', 'adjacent', 'weight', 'articulatory']);
|
||||
const chainNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'chain');
|
||||
const writeToNode = node.children.find(c => c.parentSlot === 'stem_note' && c.slot === 'write_to');
|
||||
const definiteProps = {};
|
||||
for (const child of node.children) {
|
||||
switch (child.slot) {
|
||||
case 'note':
|
||||
cluster.notes.push({ ...child.props });
|
||||
break;
|
||||
case 'pause':
|
||||
cluster.pauses.push({ length: child.props.length });
|
||||
break;
|
||||
case 'group':
|
||||
cluster.groups.push({ length: child.props.length, netlength: child.props.netlength });
|
||||
break;
|
||||
case 'subchain':
|
||||
cluster.subchains.push({ length: child.props.length, children: child.children.map(buildGeneric) });
|
||||
break;
|
||||
default:
|
||||
// ignore
|
||||
if (child.parentSlot === 'article' && child.slot === 'definite') {
|
||||
const raw = child.props.constant ?? child.props.stacked ?? child.props.static;
|
||||
const shapeChild = child.children.find(c => c.slot === 'shape');
|
||||
definiteProps[child.positionals[0]] = raw !== undefined ? coerce(raw) : (shapeChild ? _buildShape(shapeChild) : null);
|
||||
}
|
||||
}
|
||||
|
||||
return cluster;
|
||||
return {
|
||||
type: 'stem_note',
|
||||
pitch: node.props.pitch,
|
||||
effLength: node.props.eff_length ?? null,
|
||||
adjacent: node.props.adjacent ?? null,
|
||||
adjStress: node.props.adj_stress ?? null,
|
||||
length: null,
|
||||
weight: node.props.weight ?? null,
|
||||
chainText: chainNode?.props._tmp_string ?? '',
|
||||
writeToName: writeToNode?.positionals[0] ?? null,
|
||||
clauses: (chainNode?.children ?? [])
|
||||
.filter(c => c.parentSlot === 'chain' && c.slot === 'clause')
|
||||
.map(_buildClause),
|
||||
...definiteProps,
|
||||
unknownProps: _collectUnknownProps(node.props, KNOWN),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeneric(node) {
|
||||
function _buildClause(node) {
|
||||
return {
|
||||
type: 'clause',
|
||||
index: node.positionals[0] ?? 0,
|
||||
repeat: node.props.repeat ?? null,
|
||||
notes: node.children.filter(c => c.slot === 'note' && c.parentSlot === 'seq').map(c => ({ ...c.props })),
|
||||
pauses: node.children.filter(c => c.slot === 'pause' && c.parentSlot === 'seq').map(c => ({ length: c.props.length })),
|
||||
stacks: node.children.filter(c => c.slot === 'stack' && c.parentSlot === 'seq').map(c => ({
|
||||
length: c.props.length, netlength: c.props.netlength,
|
||||
notes: c.children.filter(cn => cn.slot === 'note').map(cn => ({ ...cn.props })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function _buildMotif(node) {
|
||||
const m = {
|
||||
type: 'motif',
|
||||
label: node.props.label,
|
||||
stemNotes: [],
|
||||
unknownProps: _collectUnknownProps(node.props, new Set(['label'])),
|
||||
};
|
||||
for (const child of node.children) {
|
||||
if (child.parentSlot === 'line' && child.slot === 'stem_note')
|
||||
m.stemNotes.push(_buildStemNote(child));
|
||||
}
|
||||
m.isStatic = m.stemNotes.length > 0 && !m.stemNotes.some(sn => Number.isInteger(sn.pitch));
|
||||
return m;
|
||||
}
|
||||
|
||||
function _buildGeneric(node) {
|
||||
return {
|
||||
type: node.slot,
|
||||
parentSlot: node.parentSlot,
|
||||
depth: node.depth,
|
||||
positionals: node.positionals,
|
||||
props: node.props,
|
||||
children: node.children.map(buildGeneric),
|
||||
children: node.children.map(_buildGeneric),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,61 +1,71 @@
|
||||
import { h, ref, onMounted } from 'vue';
|
||||
import { h, ref, computed, watch } from 'vue';
|
||||
import { PaneCP } from './PaneCP.js';
|
||||
import { PaneFO } from './PaneFO.js';
|
||||
import { PaneSubObjects } from './PaneSubObjects.js';
|
||||
import { ImportDialog } from './ImportDialog.js';
|
||||
import { getKindGroups, KIND_LABEL } from '../subobject-kinds.js';
|
||||
|
||||
const PANES = [
|
||||
{ id: 'cp', label: 'Position' },
|
||||
{ id: 'fo', label: 'Object' },
|
||||
{ id: 'sub', label: 'Sub-objects' },
|
||||
const FIXED_PANES = [
|
||||
{ id: 'cp', label: 'CP', title: 'Current position' },
|
||||
{ id: 'fo', label: 'FO', title: 'Focused object' },
|
||||
];
|
||||
|
||||
export const AppShell = {
|
||||
props: ['store', 'importOnLoad'],
|
||||
setup(props) {
|
||||
const activePane = ref('cp');
|
||||
const showImport = ref(false);
|
||||
|
||||
function openImport() {
|
||||
if (!props.store.isDirty) showImport.value = true;
|
||||
function focusedNode() {
|
||||
const fp = props.store.focusPath;
|
||||
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.importOnLoad && !props.store.isDirty) showImport.value = true;
|
||||
const subPanes = computed(() => getKindGroups(focusedNode()).map(g => ({
|
||||
id: `sub:${g.kind}`,
|
||||
kind: g.kind,
|
||||
label: KIND_LABEL[g.kind] ?? g.kind.slice(0, 2).toUpperCase(),
|
||||
title: g.kind,
|
||||
})));
|
||||
|
||||
const panes = computed(() => [...FIXED_PANES, ...subPanes.value]);
|
||||
|
||||
// If active pane disappears after focus change, drop back to FO.
|
||||
watch(panes, ps => {
|
||||
if (!ps.some(p => p.id === activePane.value)) activePane.value = 'fo';
|
||||
});
|
||||
|
||||
return () => {
|
||||
const store = props.store;
|
||||
const ap = activePane.value;
|
||||
|
||||
const subPaneNodes = subPanes.value.map(p =>
|
||||
h('div', { key: p.id, class: ['se-pane', ap === p.id ? 'active' : null] },
|
||||
h(PaneSubObjects, { store, kind: p.kind, onFocusFO: () => { activePane.value = 'fo'; } })));
|
||||
|
||||
return h('div', { class: 'se-shell' }, [
|
||||
// Pane area
|
||||
h('div', { class: 'se-pane-area' }, [
|
||||
h('div', { class: ['se-pane', activePane.value === 'cp' ? 'active' : null] },
|
||||
h(PaneCP, { store, onImportClick: openImport })),
|
||||
h('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] },
|
||||
h('div', { class: ['se-pane', ap === 'cp' ? 'active' : null] },
|
||||
h(PaneCP, {
|
||||
store,
|
||||
importOnLoad: props.importOnLoad,
|
||||
onFocusFO: () => { activePane.value = 'fo'; },
|
||||
})),
|
||||
h('div', { class: ['se-pane', ap === 'fo' ? 'active' : null] },
|
||||
h(PaneFO, { store })),
|
||||
h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] },
|
||||
h(PaneSubObjects, { store })),
|
||||
...subPaneNodes,
|
||||
]),
|
||||
|
||||
// Handle bar (tab switcher at bottom)
|
||||
h('div', { class: 'se-handle-bar' }, PANES.map(p =>
|
||||
h('div', { class: 'se-handle-bar' }, panes.value.map(p =>
|
||||
h('button', {
|
||||
key: p.id,
|
||||
class: ['se-handle', activePane.value === p.id ? 'active' : null],
|
||||
class: ['se-handle', ap === p.id ? 'active' : null],
|
||||
title: p.title,
|
||||
onClick: () => { activePane.value = p.id; },
|
||||
}, p.label)
|
||||
)),
|
||||
|
||||
// Error banner
|
||||
store.errorMessage
|
||||
? h('div', { class: 'se-error', style: 'margin:0' }, store.errorMessage)
|
||||
: null,
|
||||
|
||||
// Import dialog
|
||||
showImport.value
|
||||
? h(ImportDialog, { store, onClose: () => { showImport.value = false; } })
|
||||
: null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { fetchAstLog } from '../api.js';
|
||||
import { parseAstLog, buildModel } from '../ast-parser.js';
|
||||
|
||||
export const ImportDialog = {
|
||||
props: ['store'],
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function doImport() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
const creds = { username: username.value, password: password.value };
|
||||
const text = await fetchAstLog(creds);
|
||||
const rawTree = parseAstLog(text);
|
||||
props.store.scoreModel = buildModel(rawTree);
|
||||
props.store.credentials = creds;
|
||||
emit('close');
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') emit('close');
|
||||
if (e.key === 'Enter') doImport();
|
||||
}
|
||||
|
||||
return () => h('div', null, [
|
||||
h('div', { class: 'se-overlay', onClick: () => emit('close') }),
|
||||
h('div', { class: 'se-import-dialog', onKeydown: onKey, tabindex: -1 }, [
|
||||
h('h3', null, 'Import from server'),
|
||||
h('label', null, 'Username'),
|
||||
h('input', {
|
||||
type: 'text',
|
||||
value: username.value,
|
||||
onInput: e => { username.value = e.target.value; },
|
||||
autocomplete: 'username',
|
||||
}),
|
||||
h('label', null, 'Password'),
|
||||
h('input', {
|
||||
type: 'password',
|
||||
value: password.value,
|
||||
onInput: e => { password.value = e.target.value; },
|
||||
autocomplete: 'current-password',
|
||||
}),
|
||||
error.value ? h('div', { class: 'se-error' }, error.value) : null,
|
||||
h('div', { class: 'se-dialog-buttons' }, [
|
||||
h('button', { class: 'se-btn', onClick: () => emit('close') }, 'Cancel'),
|
||||
h('button', {
|
||||
class: 'se-btn se-btn-primary',
|
||||
onClick: doImport,
|
||||
disabled: loading.value,
|
||||
}, loading.value ? 'Loading…' : 'Import'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
@@ -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}`)
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,5 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Extended property view rendered as a definition list.
|
||||
// `fields`: array of { key, value, editable?, type? }
|
||||
// `onChange`: called with { key, value } when field changes.
|
||||
export const ObjectExtended = {
|
||||
props: ['fields', 'onChange'],
|
||||
setup(props) {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// One-line summary row with a drill-down chevron.
|
||||
export const ObjectShort = {
|
||||
props: ['node', 'label', 'typeTag', 'focused', 'hasChildren'],
|
||||
emits: ['focus', 'drillDown'],
|
||||
props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly', 'deletable'],
|
||||
emits: ['focus', 'drillDown', 'delete'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('li', {
|
||||
class: ['se-object-item', props.focused ? 'focused' : null],
|
||||
class: ['se-object-item', props.focused ? 'focused' : null, props.readOnly ? 'read-only' : null],
|
||||
onClick: () => emit('focus'),
|
||||
}, [
|
||||
props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null,
|
||||
h('span', { class: 'se-object-label' }, props.label),
|
||||
props.deletable
|
||||
? h('button', {
|
||||
class: 'se-btn-delete',
|
||||
title: 'Delete',
|
||||
onClick: e => { e.stopPropagation(); emit('delete'); },
|
||||
}, '×')
|
||||
: null,
|
||||
props.hasChildren
|
||||
? h('button', {
|
||||
class: 'se-chevron',
|
||||
|
||||
@@ -1,34 +1,58 @@
|
||||
import { h, ref } from 'vue';
|
||||
import { fetchScoreText, putScoreText } from '../api.js';
|
||||
import { h, ref, watch, onMounted } from 'vue';
|
||||
import { fetchAstLog, fetchScoreText, putScoreText, fetchAudioWidget, URLS } from '../api.js';
|
||||
import { parseAstLog, buildModel } from '../ast-parser.js';
|
||||
import { patchScore } from '../exporter.js';
|
||||
import { shortView } from '../node-views.js';
|
||||
import { StatusPoller } from './StatusPoller.js';
|
||||
|
||||
export const PaneCP = {
|
||||
props: ['store', 'onImportClick'],
|
||||
props: ['store', 'importOnLoad', 'onFocusFO'],
|
||||
setup(props) {
|
||||
const importing = ref(false);
|
||||
const importError = ref('');
|
||||
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 doImport() {
|
||||
if (props.store.isDirty) return;
|
||||
importError.value = '';
|
||||
importing.value = true;
|
||||
try {
|
||||
const text = await fetchAstLog();
|
||||
props.store.scoreModel = buildModel(parseAstLog(text));
|
||||
props.store.resetEditState();
|
||||
} catch (e) {
|
||||
importError.value = e.message;
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
exportError.value = '';
|
||||
exporting.value = true;
|
||||
try {
|
||||
const raw = await fetchScoreText(props.store.credentials);
|
||||
const raw = await fetchScoreText();
|
||||
props.store.rawScoreText = raw;
|
||||
const patched = patchScore(raw, props.store.scoreModel.instruments);
|
||||
await putScoreText(patched, props.store.credentials);
|
||||
// Start polling
|
||||
props.store.synthesisStatus = { frozen: false, progress: 0 };
|
||||
const model = props.store.scoreModel;
|
||||
const { text: patched, log } = patchScore(
|
||||
raw, model.instruments, model.bars, model.info, model.articles ?? [],
|
||||
);
|
||||
props.store.exportLog = log;
|
||||
await putScoreText(patched);
|
||||
props.store.synthesisStatus = { frozen: false, currently_rendered_notes: 0, notes_in_total: 0 };
|
||||
} catch (e) {
|
||||
exportError.value = e.message;
|
||||
} finally {
|
||||
@@ -36,61 +60,87 @@ export const PaneCP = {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { if (props.importOnLoad) doImport(); });
|
||||
|
||||
return () => {
|
||||
const store = props.store;
|
||||
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,
|
||||
disabled: store.isDirty || importing.value,
|
||||
title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server',
|
||||
onClick: props.onImportClick,
|
||||
}, 'Import'),
|
||||
onClick: doImport,
|
||||
}, importing.value ? 'Importing…' : '→ 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,
|
||||
// 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,
|
||||
|
||||
// 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,
|
||||
|
||||
// Export error
|
||||
// Import / export / synthesis errors
|
||||
importError.value ? h('div', { class: 'se-error' }, importError.value) : null,
|
||||
exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null,
|
||||
store.synthesisStatus?.frozen && store.synthesisStatus.errors
|
||||
? h('div', { class: 'se-error' }, store.synthesisStatus.errors) : null,
|
||||
|
||||
// Status poller (shown after export started)
|
||||
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||
? h(StatusPoller, { store })
|
||||
// Export log (shown after export, cleared on next import)
|
||||
store.exportLog?.length
|
||||
? h('div', { class: 'se-export-log' },
|
||||
store.exportLog.map((entry, i) =>
|
||||
h('div', {
|
||||
key: i,
|
||||
class: entry.level === 'changed' ? 'se-export-changed' : 'se-export-info',
|
||||
}, entry.path ? `→ ${entry.path}` : entry.message)
|
||||
)
|
||||
)
|
||||
: null,
|
||||
|
||||
// Result link
|
||||
store.synthesisStatus?.frozen && !store.synthesisStatus?.error
|
||||
? h('a', {
|
||||
href: '/sompyle/result.mp3',
|
||||
style: 'display:block;margin-top:0.5rem',
|
||||
}, 'Download result')
|
||||
// Status poller (while running)
|
||||
store.synthesisStatus && !store.synthesisStatus.frozen
|
||||
? h(StatusPoller, { store }) : null,
|
||||
|
||||
// Audio widget (rendered server-side from audiowidget.tmpl)
|
||||
audioWidgetHtml.value
|
||||
? h('div', { innerHTML: audioWidgetHtml.value, style: 'margin-top:0.5rem' })
|
||||
: null,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
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 { coerce, stressorToString } from '../util.js';
|
||||
|
||||
function instrFields(instr) {
|
||||
const H4 = { style: 'margin:0 0 0.5rem' };
|
||||
|
||||
function _unknownPropFields(node) {
|
||||
return Object.entries(node.unknownProps ?? {})
|
||||
.map(([key, value]) => ({ key, value: String(value), editable: false }));
|
||||
}
|
||||
|
||||
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: 'name', value: instr.name, editable: false },
|
||||
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
|
||||
{ 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: 'NOT_CHANGED_SINCE', value: instr.notChangedSince ?? '—', editable: false },
|
||||
];
|
||||
}
|
||||
|
||||
function variationFields(v) {
|
||||
return [
|
||||
{ key: 'depends_on', value: v.dependsOn ?? '—', editable: true },
|
||||
];
|
||||
function _variationFields(v) {
|
||||
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 () => {
|
||||
if (instr.isLinked && !instr.isDirty) {
|
||||
// Stash the apply callback and show the modal.
|
||||
pendingEdit.value = { instr, apply };
|
||||
// Guard: first edit to a linked instrument triggers embed-or-discard before committing.
|
||||
function makeChangeHandler(instr) {
|
||||
return (info) => {
|
||||
if (instr.isLinked && !instr._modified) {
|
||||
pendingEdit.value = { instr, undo: info?.undo };
|
||||
} else {
|
||||
apply();
|
||||
instr.isDirty = true;
|
||||
instr._modified = true;
|
||||
props.store.markDirty();
|
||||
}
|
||||
};
|
||||
@@ -46,77 +73,230 @@ export const PaneFO = {
|
||||
function embedInstrument(instr) {
|
||||
instr.name = instr.name.split('/').pop();
|
||||
instr.isLinked = false;
|
||||
instr.isDirty = true;
|
||||
pendingEdit.value.apply();
|
||||
instr._modified = true;
|
||||
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(ObjectExtended, { fields: instrFields(node), onChange: null }),
|
||||
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(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
|
||||
if (key === 'depends_on') node.dependsOn = value;
|
||||
onChange();
|
||||
h('h4', H4, 'Variation'),
|
||||
h(ObjectExtended, { fields: _variationFields(node), onChange: ({ key, value }) => {
|
||||
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') {
|
||||
} else if (node.type === 'article') {
|
||||
const markDirty = () => { props.store.markDirty(); };
|
||||
const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0';
|
||||
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, `Article: ${node.name}`),
|
||||
h('ul', { class: 'se-article-props', style: 'list-style:none;padding:0;margin:0' }, [
|
||||
...node.properties.map((p, idx) => {
|
||||
const isOverwrite = p.scope === 'overwrites';
|
||||
const flip = () => { p.scope = isOverwrite ? 'defaults' : 'overwrites'; markDirty(); };
|
||||
const onKeyInput = (e) => { p.name = e.target.value; markDirty(); };
|
||||
const onValInput = (e) => { p.value = coerce(e.target.value); markDirty(); };
|
||||
const onRemove = () => { node.properties.splice(idx, 1); markDirty(); };
|
||||
return h('li', { key: idx, style: rowStyle }, [
|
||||
h('input', {
|
||||
class: 'se-prop-key',
|
||||
value: p.name,
|
||||
style: 'flex:1;min-width:6em',
|
||||
onInput: onKeyInput,
|
||||
}),
|
||||
h('span', null, '='),
|
||||
h('input', {
|
||||
class: 'se-prop-val',
|
||||
value: String(p.value),
|
||||
style: 'flex:1;min-width:6em',
|
||||
onInput: onValInput,
|
||||
}),
|
||||
h('div', { class: 'form-check form-switch mb-0' }, [
|
||||
h('input', {
|
||||
class: 'form-check-input mt-0',
|
||||
type: 'checkbox',
|
||||
role: 'switch',
|
||||
id: `prop-scope-${idx}`,
|
||||
checked: isOverwrite,
|
||||
onChange: flip,
|
||||
}),
|
||||
h('label', {
|
||||
class: 'form-check-label',
|
||||
for: `prop-scope-${idx}`,
|
||||
}, isOverwrite ? 'overwrite' : 'default'),
|
||||
]),
|
||||
h('button', {
|
||||
class: 'se-btn-remove',
|
||||
title: 'Remove property',
|
||||
onClick: onRemove,
|
||||
}, '×'),
|
||||
]);
|
||||
}),
|
||||
h('li', { key: '__add', style: rowStyle },
|
||||
h('button', {
|
||||
class: 'se-btn',
|
||||
onClick: () => {
|
||||
node.properties.push({ name: '', value: '', scope: 'defaults' });
|
||||
markDirty();
|
||||
},
|
||||
}, '+ add property')),
|
||||
]),
|
||||
);
|
||||
} else if (node.type === 'bar') {
|
||||
const markBarDirty = () => { props.store.markDirty(); };
|
||||
children.push(
|
||||
h('h4', H4, `Bar: ${node.id}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'id', value: node.id, editable: node._isNew ?? 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 === 'id') { node.id = value; markBarDirty(); return; }
|
||||
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.map(a => a.name).join(', ') || '—', editable: false },
|
||||
{ key: 'motifs', value: node.motifs.map(m => m.label).join(', ') || '—', editable: false },
|
||||
{ key: 'offsets', value: String(node.offsets.length), editable: false },
|
||||
],
|
||||
onChange: null,
|
||||
}),
|
||||
);
|
||||
} else if (node.type === 'offset') {
|
||||
const snLabel = sn => `${sn.pitch}${sn.effLength != null ? ' /' + sn.effLength : ''}${sn.clauses.length ? ' ×' + sn.clauses.length : ''}`;
|
||||
children.push(
|
||||
h('h4', H4, `Tick: ${node.tick}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'tick', value: node.tick, editable: false },
|
||||
{ key: 'stem notes', value: node.stemNotes.map(snLabel).join(', ') || '—', editable: false },
|
||||
node.motifRefs?.length
|
||||
? { key: 'motifRefs', value: node.motifRefs.map(m => m.chord ? `${m.label}(${m.chord})` : m.label).join(', '), editable: false }
|
||||
: null,
|
||||
..._unknownPropFields(node),
|
||||
].filter(Boolean),
|
||||
onChange: null,
|
||||
}),
|
||||
);
|
||||
} else if (node.type === 'motif') {
|
||||
const pitchLabel = sn => (Number.isInteger(sn.pitch) ? `(ref${sn.pitch !== 0 ? sn.pitch : ''})` : String(sn.pitch))
|
||||
+ (sn.clauses.length ? ' ×' + sn.clauses.length : '');
|
||||
children.push(
|
||||
h('h4', H4, `Motif: ${node.label}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'label', value: node.label, editable: false },
|
||||
{ key: 'static', value: node.isStatic, editable: false, type: 'boolean' },
|
||||
{ key: 'stem notes', value: node.stemNotes.map(pitchLabel).join(', ') || '—', editable: false },
|
||||
..._unknownPropFields(node),
|
||||
],
|
||||
onChange: null,
|
||||
}),
|
||||
);
|
||||
} else if (node.type === 'stem_note') {
|
||||
const markDirty = () => {
|
||||
props.store.markDirty();
|
||||
};
|
||||
children.push(
|
||||
h('h4', H4, `Stem note: ${node.pitch}`),
|
||||
h(ObjectExtended, {
|
||||
fields: [
|
||||
{ key: 'pitch', value: node.pitch, editable: true },
|
||||
{ key: 'length', value: node.length ?? '', editable: true },
|
||||
{ key: 'weight', value: node.weight != null ? String(node.weight) : '', editable: true, type: 'number' },
|
||||
{ key: 'adj_stress', value: node.adjStress != null ? String(node.adjStress) : '', editable: true, type: 'number' },
|
||||
{ key: 'chain', value: node.chainText, editable: true },
|
||||
{ key: 'clauses', value: String(node.clauses.length), editable: false },
|
||||
..._unknownPropFields(node),
|
||||
],
|
||||
onChange: ({ key, value }) => {
|
||||
if (key === 'pitch') node.pitch = value;
|
||||
if (key === 'length') node.length = value || null;
|
||||
if (key === 'weight') node.weight = value === '' ? null : Number(value);
|
||||
if (key === 'adj_stress') node.adjStress = value === '' ? null : Number(value);
|
||||
if (key === 'chain') node.chainText = value;
|
||||
markDirty();
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' },
|
||||
@@ -128,8 +308,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,
|
||||
]);
|
||||
|
||||
@@ -1,67 +1,164 @@
|
||||
import { h } from 'vue';
|
||||
import { ObjectShort } from './ObjectShort.js';
|
||||
import { getKindGroups } from '../subobject-kinds.js';
|
||||
|
||||
function _barGroupKey(id) {
|
||||
const m = id.match(/(\d+|\D+)$/);
|
||||
return m ? id.slice(0, m.index) : id;
|
||||
}
|
||||
|
||||
function _incrementId(id) {
|
||||
const m = id.match(/(\d+)$/);
|
||||
if (!m) return id + '1';
|
||||
const num = parseInt(m[1], 10) + 1;
|
||||
const padded = String(num).padStart(m[1].length, '0');
|
||||
return id.slice(0, m.index) + padded;
|
||||
}
|
||||
|
||||
// Shows sub-objects of the currently focused node — variations, bars, voices, etc.
|
||||
export const PaneSubObjects = {
|
||||
props: ['store'],
|
||||
props: ['store', 'kind', 'onFocusFO'],
|
||||
setup(props) {
|
||||
function focused() {
|
||||
const fp = props.store.focusPath;
|
||||
return fp.length ? fp[fp.length - 1] : props.store.scoreModel;
|
||||
}
|
||||
|
||||
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 })),
|
||||
];
|
||||
function _spliceNode(arr, node) {
|
||||
const idx = arr.indexOf(node);
|
||||
if (idx !== -1) { arr.splice(idx, 1); props.store.markDirty(); }
|
||||
}
|
||||
|
||||
function deleteItem(kind, node) {
|
||||
const store = props.store;
|
||||
const model = store.scoreModel;
|
||||
if (!model) return;
|
||||
|
||||
if (kind === 'instrument') {
|
||||
_spliceNode(model.instruments, node);
|
||||
} else if (kind === 'articles') {
|
||||
_spliceNode(model.articles, node);
|
||||
} else if (kind === 'bar') {
|
||||
_spliceNode(model.bars, node);
|
||||
} else if (kind === 'variation') {
|
||||
for (const instr of model.instruments) {
|
||||
const idx = instr.variations.indexOf(node);
|
||||
if (idx !== -1) { instr.variations.splice(idx, 1); instr._modified = true; store.markDirty(); return; }
|
||||
for (const v of instr.variations) {
|
||||
const sidx = v.subvariations.indexOf(node);
|
||||
if (sidx !== -1) { v.subvariations.splice(sidx, 1); instr._modified = true; store.markDirty(); return; }
|
||||
}
|
||||
}
|
||||
} else if (kind === 'label_spec') {
|
||||
for (const instr of model.instruments) {
|
||||
for (const v of instr.variations) {
|
||||
const idx = v.labelSpecs.indexOf(node);
|
||||
if (idx !== -1) { v.labelSpecs.splice(idx, 1); instr._modified = true; store.markDirty(); return; }
|
||||
for (const sv of v.subvariations) {
|
||||
const idx2 = sv.labelSpecs.indexOf(node);
|
||||
if (idx2 !== -1) { sv.labelSpecs.splice(idx2, 1); instr._modified = true; store.markDirty(); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === 'instrument') {
|
||||
return node.variations.map((v, idx) => ({
|
||||
kind: 'variation',
|
||||
node: v,
|
||||
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function addBar(afterId) {
|
||||
const store = props.store;
|
||||
const model = store.scoreModel;
|
||||
if (!model) return;
|
||||
let newId;
|
||||
if (afterId) {
|
||||
newId = _incrementId(afterId);
|
||||
} else {
|
||||
const liveBars = model.bars ?? [];
|
||||
newId = liveBars.length ? _incrementId(liveBars[liveBars.length - 1].id) : 'bar001';
|
||||
}
|
||||
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}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
if (node.type === 'bar') {
|
||||
return Object.entries(node.voices).map(([name, v]) => ({
|
||||
kind: 'voice', node: v, label: name,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
const newBar = {
|
||||
type: 'bar', id: newId, _isNew: true,
|
||||
stressor: null, tempoLevels: null,
|
||||
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
||||
voices: {},
|
||||
};
|
||||
model.bars.push(newBar);
|
||||
store.markDirty();
|
||||
store.pushFocus(newBar);
|
||||
props.onFocusFO?.();
|
||||
}
|
||||
|
||||
return () => {
|
||||
const node = focused();
|
||||
const items = subItems(node);
|
||||
if (!items.length) return h('div', { class: 'se-pane' }, h('em', null, 'No sub-objects'));
|
||||
const groups = getKindGroups(node);
|
||||
const group = props.kind
|
||||
? groups.find(g => g.kind === props.kind)
|
||||
: groups[0];
|
||||
const items = group?.items ?? [];
|
||||
|
||||
return h('div', { class: 'se-pane' }, [
|
||||
if (!items.length && props.kind !== 'bar') return h('div', null, h('em', null, 'No sub-objects'));
|
||||
|
||||
if (props.kind === 'bar') {
|
||||
const barGroups = [];
|
||||
const seen = new Map();
|
||||
for (const item of items) {
|
||||
const key = _barGroupKey(item.label);
|
||||
if (!seen.has(key)) {
|
||||
const g = { key, items: [] };
|
||||
barGroups.push(g);
|
||||
seen.set(key, g);
|
||||
}
|
||||
seen.get(key).items.push(item);
|
||||
}
|
||||
|
||||
const groupEls = barGroups.map(g => {
|
||||
const lastId = g.items[g.items.length - 1].label;
|
||||
return h('div', { key: g.key, class: 'se-bar-group' }, [
|
||||
...g.items.map((item, idx) =>
|
||||
h('span', {
|
||||
key: idx,
|
||||
class: ['se-bar-chip', props.store.focusPath.includes(item.node) ? 'focused' : null],
|
||||
}, [
|
||||
h('span', {
|
||||
class: 'se-bar-chip-label',
|
||||
onClick: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||
}, item.label),
|
||||
h('button', {
|
||||
class: 'se-bar-chip-delete',
|
||||
title: 'Delete bar',
|
||||
onClick: e => { e.stopPropagation(); deleteItem('bar', item.node); },
|
||||
}, '×'),
|
||||
])
|
||||
),
|
||||
h('button', {
|
||||
class: 'se-bar-add',
|
||||
title: `Add bar after ${lastId}`,
|
||||
onClick: () => addBar(lastId),
|
||||
}, '+ add'),
|
||||
]);
|
||||
});
|
||||
|
||||
return h('div', { class: 'se-bar-groups' }, [
|
||||
...groupEls,
|
||||
h('div', { class: 'se-bar-group' },
|
||||
h('button', { class: 'se-bar-add', onClick: () => addBar(null) }, '+ add bar')
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
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,
|
||||
readOnly: item.readOnly ?? false,
|
||||
deletable: item.deletable ?? false,
|
||||
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||
onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
|
||||
onDelete: item.deletable ? () => deleteItem(item.kind, item.node) : undefined,
|
||||
})
|
||||
)),
|
||||
]);
|
||||
))
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { h } from 'vue';
|
||||
|
||||
// Renders a shape's coord table with cascade-shift and an SVG preview.
|
||||
// `shape` is mutated in place; `onChange` called after each mutation.
|
||||
// `shape` is mutated in place; `onChange` called after each mutation with
|
||||
// { undo } so callers can revert if needed.
|
||||
|
||||
export const ShapeEditor = {
|
||||
props: ['shape', 'onChange'],
|
||||
@@ -14,27 +15,31 @@ export const ShapeEditor = {
|
||||
const delta = num - old;
|
||||
coord[field] = num;
|
||||
|
||||
// cascade-shift: if x increased past next coord, shift all following
|
||||
const shifted = [];
|
||||
if (field === 'x' && delta > 0) {
|
||||
for (let j = i + 1; j < props.shape.coords.length; j++) {
|
||||
if (props.shape.coords[j].x <= num) {
|
||||
shifted.push({ j, ox: props.shape.coords[j].x });
|
||||
props.shape.coords[j].x += delta;
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
props.onChange?.();
|
||||
props.onChange?.({ undo: () => {
|
||||
coord[field] = old;
|
||||
for (const { j, ox } of shifted) props.shape.coords[j].x = ox;
|
||||
}});
|
||||
}
|
||||
|
||||
function addCoord() {
|
||||
const coords = props.shape.coords;
|
||||
const lastX = coords.length ? coords[coords.length - 1].x + 1 : 1;
|
||||
coords.push({ x: lastX, y: 0, z: 1, isSharp: false });
|
||||
props.onChange?.();
|
||||
props.onChange?.({ undo: () => coords.pop() });
|
||||
}
|
||||
|
||||
function removeCoord(i) {
|
||||
props.shape.coords.splice(i, 1);
|
||||
props.onChange?.();
|
||||
const removed = props.shape.coords.splice(i, 1)[0];
|
||||
props.onChange?.({ undo: () => props.shape.coords.splice(i, 0, removed) });
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
@@ -55,19 +60,14 @@ export const ShapeEditor = {
|
||||
return [sx, sy];
|
||||
};
|
||||
|
||||
const points = coords.map(c => toSvg(c).join(',') ).join(' ');
|
||||
const points = coords.map(c => toSvg(c).join(',')).join(' ');
|
||||
|
||||
return h('svg', {
|
||||
class: 'se-shape-svg',
|
||||
viewBox: `0 0 ${W} ${H}`,
|
||||
preserveAspectRatio: 'none',
|
||||
}, [
|
||||
h('polyline', {
|
||||
points,
|
||||
fill: 'none',
|
||||
stroke: '#2a6aaa',
|
||||
'stroke-width': '1.5',
|
||||
}),
|
||||
h('polyline', { points, fill: 'none', stroke: '#2a6aaa', 'stroke-width': '1.5' }),
|
||||
...coords.map(c => {
|
||||
const [sx, sy] = toSvg(c);
|
||||
return h('circle', { cx: sx, cy: sy, r: 2.5, fill: '#6aacff' });
|
||||
@@ -101,7 +101,11 @@ export const ShapeEditor = {
|
||||
})),
|
||||
h('td', null, h('input', {
|
||||
type: 'checkbox', checked: !!coord.isSharp,
|
||||
onChange: e => { coord.isSharp = e.target.checked; props.onChange?.(); },
|
||||
onChange: e => {
|
||||
const old = coord.isSharp;
|
||||
coord.isSharp = e.target.checked;
|
||||
props.onChange?.({ undo: () => { coord.isSharp = old; } });
|
||||
},
|
||||
})),
|
||||
h('td', null, h('button', { onClick: () => removeCoord(i) }, '✕')),
|
||||
])
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
import { h, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { h, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { fetchStatus } from '../api.js';
|
||||
|
||||
export const StatusPoller = {
|
||||
props: ['store'],
|
||||
setup(props) {
|
||||
const timer = ref(null);
|
||||
const eta = ref(null);
|
||||
|
||||
function nextInterval(status) {
|
||||
if (!status) return 2000;
|
||||
const pct = status.progress ?? 0;
|
||||
// At 0–20%: poll at centile-of-ETA intervals
|
||||
if (eta.value && pct > 0 && pct <= 20) {
|
||||
return Math.max(500, (eta.value * 10) | 0);
|
||||
}
|
||||
return 2000;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const status = await fetchStatus(props.store.credentials);
|
||||
if (status.eta) eta.value = status.eta;
|
||||
const status = await fetchStatus();
|
||||
props.store.synthesisStatus = status;
|
||||
if (status.frozen) {
|
||||
// done — stop polling
|
||||
return;
|
||||
}
|
||||
if (status.frozen) return;
|
||||
} catch (_) {
|
||||
// transient error — keep polling
|
||||
// transient — keep polling
|
||||
}
|
||||
timer.value = setTimeout(poll, nextInterval(props.store.synthesisStatus));
|
||||
timer.value = setTimeout(poll, 2000);
|
||||
}
|
||||
|
||||
onMounted(() => { poll(); });
|
||||
@@ -39,10 +24,16 @@ export const StatusPoller = {
|
||||
const s = props.store.synthesisStatus;
|
||||
if (!s) return h('div', { class: 'se-status' }, 'Polling…');
|
||||
|
||||
const pct = s.progress ?? 0;
|
||||
const label = s.frozen
|
||||
? (s.error ? `Error: ${s.error}` : 'Done')
|
||||
: `${s.state ?? 'Running'} ${pct}%`;
|
||||
const total = s.notes_in_total ?? 0;
|
||||
const done = s.currently_rendered_notes ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, Math.round(done / total * 100)) : 0;
|
||||
|
||||
let label;
|
||||
if (s.frozen) {
|
||||
label = s.errors ? `Error: ${s.errors}` : 'Done';
|
||||
} else {
|
||||
label = s.remaining_time ?? `Synthesizing… ${pct}%`;
|
||||
}
|
||||
|
||||
return h('div', { class: 'se-status' }, [
|
||||
h('span', null, label),
|
||||
|
||||
@@ -1,145 +1,240 @@
|
||||
// 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.
|
||||
import { stressorToString } from './util.js';
|
||||
|
||||
function fillTemplate(template, slots) {
|
||||
return template.replace(/#(\d+)/g, (_, i) => slots[parseInt(i, 10)] ?? '');
|
||||
// RFC §1.3.4.5: SHAPE = [PREFIX (":" / ";")] Node 1*(";" Node)
|
||||
// Node = x "," y ["*" z] ["!"]
|
||||
// PREFIX+colon is the duration/resolution; optional START+semicolon follows.
|
||||
|
||||
function _serializeShape(shape) {
|
||||
if (!shape) return null;
|
||||
const nodes = shape.coords.map(c => {
|
||||
let s = `${c.x},${c.y}`;
|
||||
if (c.z !== undefined && c.z !== 1) s += `*${c.z}`;
|
||||
if (c.isSharp) s += '!';
|
||||
return s;
|
||||
}).join(';');
|
||||
let prefix = '';
|
||||
if (shape.length != null) prefix = `${shape.length}:`;
|
||||
if (shape.start != null) prefix += `${shape.start};`;
|
||||
return prefix + nodes;
|
||||
}
|
||||
|
||||
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 §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
|
||||
|
||||
// ── Shape ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 _serializeModulation(modulation) {
|
||||
let s = String(modulation.frequency ?? '');
|
||||
if (modulation.oscillator) s += `@${modulation.oscillator}`;
|
||||
if (modulation.shape) s += `[${_serializeShape(modulation.shape)}]`;
|
||||
s += `;${modulation.mod_share ?? ''}:${modulation.base_share ?? ''}`;
|
||||
if (modulation.init_phase != null) s += modulation.init_phase >= 0 ? `+${modulation.init_phase}` : String(modulation.init_phase);
|
||||
return s;
|
||||
}
|
||||
|
||||
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);
|
||||
// RFC §3.2.1.1: O, A, S, R, FM go directly in the variation MAPPING.
|
||||
|
||||
function _basicPropLines(bp) {
|
||||
if (!bp) return [];
|
||||
const a = _serializeShape(bp.A), s = _serializeShape(bp.S), r = _serializeShape(bp.R);
|
||||
return [
|
||||
bp.oscillator ? `O: ${bp.oscillator}` : null,
|
||||
a ? `A: "${a}"` : null,
|
||||
s ? `S: "${s}"` : null,
|
||||
r ? `R: "${r}"` : null,
|
||||
...(bp.fmModulations ?? []).map(fm => `FM: "${_serializeModulation(fm)}"`),
|
||||
...(bp.amModulations ?? []).map(am => `AM: "${_serializeModulation(am)}"`),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
// ── BasicProperties ────────────────────────────────────────────────────────
|
||||
// RFC §3.2.1.2: label name (3+ lowercase chars) is the MAPPING KEY directly.
|
||||
|
||||
function exportBasicProperties(bp, level) {
|
||||
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}`);
|
||||
function _labelSpecLines(ls) {
|
||||
const inner = _basicPropLines(ls.basicProperties);
|
||||
if (!inner.length) return [`${ls.label}:`];
|
||||
return [`${ls.label}:`, ...inner.map(l => ` ${l}`)];
|
||||
}
|
||||
|
||||
// RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level.
|
||||
|
||||
function _variationLines(variation) {
|
||||
const rc = _serializeShape(variation.railsbackCurve);
|
||||
const vol = _serializeShape(variation.volumes);
|
||||
const timbre = _serializeShape(variation.timbre);
|
||||
return [
|
||||
variation.dependsOn ? `ATTR: ${variation.dependsOn}` : null,
|
||||
..._basicPropLines(variation.basicProperties),
|
||||
...(variation.labelSpecs ?? []).flatMap(_labelSpecLines),
|
||||
variation.spread?.length ? `SPREAD: [${variation.spread.join(', ')}]` : null,
|
||||
rc ? `RAILSBACK_CURVE: "${rc}"` : null,
|
||||
vol ? `VOLUMES: "${vol}"` : null,
|
||||
timbre ? `TIMBRE: "${timbre}"` : null,
|
||||
...(variation.fmModulations ?? []).map(fm => `FM: "${_serializeModulation(fm)}"`),
|
||||
...(variation.amModulations ?? []).map(am => `AM: "${_serializeModulation(am)}"`),
|
||||
...(variation.subvariations ?? []).flatMap(_variationLines),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
// VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser
|
||||
// stores them on the instrument because they appear at depth 01 (root variation
|
||||
// is implicit when no character: wrapper exists). Promote them into a synthetic
|
||||
// root variation here so the export structure is RFC-correct.
|
||||
|
||||
function _instrCharacterLines(instr) {
|
||||
const variations = instr.variations ?? [];
|
||||
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
|
||||
(instr.fmModulations ?? []).length > 0 ||
|
||||
(instr.amModulations ?? []).length > 0;
|
||||
const syntheticRoot = hasRootProps
|
||||
? {
|
||||
basicProperties: instr.basicProperties,
|
||||
labelSpecs: [], subvariations: [], spread: null,
|
||||
dependsOn: null, railsbackCurve: null,
|
||||
volumes: instr.volumes,
|
||||
timbre: instr.timbre,
|
||||
fmModulations: instr.fmModulations ?? [],
|
||||
amModulations: instr.amModulations ?? [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const allVariations = [
|
||||
...(syntheticRoot ? [syntheticRoot] : []),
|
||||
...variations,
|
||||
];
|
||||
|
||||
if (allVariations.length <= 1) {
|
||||
const vLines = allVariations.length ? _variationLines(allVariations[0]) : [];
|
||||
return vLines.map(l => ` ${l}`);
|
||||
}
|
||||
if (!lines.length) return '';
|
||||
return indent('basic_properties:\n' + lines.join('\n'), level);
|
||||
|
||||
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
|
||||
return allVariations.flatMap(v => {
|
||||
const vLines = _variationLines(v);
|
||||
if (!vLines.length) return [];
|
||||
return [` - ${vLines[0]}`, ...vLines.slice(1).map(l => ` ${l}`)];
|
||||
});
|
||||
}
|
||||
|
||||
// ── LabelSpec ─────────────────────────────────────────────────────────────
|
||||
// RFC §4.4.1: DATE = YYYY-MM-DD HH:MM:SS
|
||||
// notChangedSince from AST log is a float epoch; score YAML must have ISO date.
|
||||
|
||||
function 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 _epochToISO(val) {
|
||||
if (!val) return null;
|
||||
if (typeof val === 'string') return val;
|
||||
return new Date(val * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
// ── Variation ─────────────────────────────────────────────────────────────
|
||||
|
||||
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 _nowISO() {
|
||||
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
// ── 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}:`];
|
||||
lines.push(` NOT_CHANGED_SINCE: ${_nowISO()}`);
|
||||
lines.push(` character:`);
|
||||
lines.push(..._instrCharacterLines(instr));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Score patch ────────────────────────────────────────────────────────────
|
||||
// RFC §4.3: articles: MAPPING { LABEL: { ATTR: VALUE ... } ... }
|
||||
|
||||
// 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.
|
||||
function _serializeArticleValue(v) {
|
||||
if (typeof v === 'boolean') return String(v);
|
||||
if (typeof v === 'number') return String(v);
|
||||
const s = String(v);
|
||||
return /[:#\[\]{}&*!,|>'"%@`]/.test(s) ? JSON.stringify(s) : s;
|
||||
}
|
||||
|
||||
const lines = rawScoreText.split('\n');
|
||||
const result = [];
|
||||
const instrMap = {};
|
||||
for (const instr of instruments) {
|
||||
const basename = instr.name.includes('/') ? instr.name.split('/').pop() : instr.name;
|
||||
instrMap[basename] = instr;
|
||||
instrMap[instr.name] = instr;
|
||||
function _buildArticlesBlock(articles) {
|
||||
const body = articles.flatMap(art => {
|
||||
const defaults = (art.properties ?? []).filter(p => p.scope !== 'overwrites' && p.name);
|
||||
const overwrites = (art.properties ?? []).filter(p => p.scope === 'overwrites' && p.name);
|
||||
if (!defaults.length && !overwrites.length) return [];
|
||||
const lines = [` ${art.name}:`];
|
||||
defaults.forEach(p => lines.push(` ${p.name}: ${_serializeArticleValue(p.value)}`));
|
||||
if (overwrites.length) {
|
||||
lines.push(` -important:`);
|
||||
overwrites.forEach(p => lines.push(` - ${p.name}`));
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
return body.length ? ['articles:', ...body].join('\n') : null;
|
||||
}
|
||||
|
||||
function _patchArticles(text, articles) {
|
||||
const newBlock = _buildArticlesBlock(articles);
|
||||
const lines = text.split('\n');
|
||||
const artIdx = lines.findIndex(l => /^articles\s*:/.test(l));
|
||||
if (artIdx !== -1) {
|
||||
let end = artIdx + 1;
|
||||
while (end < lines.length && (lines[end] === '' || lines[end].startsWith(' ') || lines[end].startsWith('\t'))) end++;
|
||||
if (newBlock) lines.splice(artIdx, end - artIdx, newBlock);
|
||||
else lines.splice(artIdx, end - artIdx);
|
||||
} else if (newBlock) {
|
||||
const instrIdx = lines.findIndex(l => /^instrument\s/.test(l));
|
||||
const insertAt = instrIdx !== -1 ? instrIdx : lines.length;
|
||||
lines.splice(insertAt, 0, newBlock, '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Voice note content in bar documents is left verbatim.
|
||||
|
||||
const META_KEYS = ['title', 'composer', 'source', 'encrypter'];
|
||||
|
||||
function _patchMetadata(text, info) {
|
||||
if (!info) return text;
|
||||
const lines = text.split('\n');
|
||||
const replaced = new Set();
|
||||
|
||||
const out = lines.map(line => {
|
||||
const key = META_KEYS.find(k => line.startsWith(k + ':') && info[k] != null && info[k] !== '');
|
||||
if (key) { replaced.add(key); return `${key}: ${info[key]}`; }
|
||||
return line;
|
||||
});
|
||||
|
||||
const prepend = META_KEYS
|
||||
.filter(k => !replaced.has(k) && info[k] != null && info[k] !== '')
|
||||
.map(k => `${k}: ${info[k]}`);
|
||||
if (prepend.length) out.unshift(...prepend);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function _patchInstrumentHeader(text, instruments) {
|
||||
const lines = text.split('\n');
|
||||
const result = [];
|
||||
const instrMap = Object.fromEntries(instruments.flatMap(instr => {
|
||||
const entries = [[instr.name, instr]];
|
||||
if (instr.name.includes('/')) entries.push([instr.name.split('/').pop(), instr]);
|
||||
return entries;
|
||||
}));
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const m = line.match(/^instrument:\s+'?([^']+)'?/);
|
||||
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
|
||||
if (!instr) {
|
||||
// not in model → deleted; skip block
|
||||
i++;
|
||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||
} else if (instr.isLinked && !instr._modified) {
|
||||
result.push(line);
|
||||
i++;
|
||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) {
|
||||
if (/^\s+NOT_CHANGED_SINCE\s*:/.test(lines[i]))
|
||||
result.push(` NOT_CHANGED_SINCE: ${_epochToISO(instr.notChangedSince)}`);
|
||||
else
|
||||
result.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++;
|
||||
result.push(exportInstrument(instr));
|
||||
result.push('');
|
||||
} else {
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
result.push(line);
|
||||
@@ -149,3 +244,153 @@ export function patchScore(rawScoreText, instruments) {
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
|
||||
// Remove stage voice entries whose referenced instrument is no longer in the model.
|
||||
// String form: ` voicename: LEFT|RIGHT DIST [Instrument]` — 3rd token is instrument.
|
||||
// Mapping form: ` voicename:\n instrument: Name` — look for `instrument:` child.
|
||||
// When no explicit instrument is present, the voice name is the implicit instrument name.
|
||||
|
||||
function _patchStageSection(text, instruments) {
|
||||
const instrNames = new Set(instruments.flatMap(i => {
|
||||
const names = [i.name];
|
||||
if (i.name.includes('/')) names.push(i.name.split('/').pop());
|
||||
return names;
|
||||
}));
|
||||
const lines = text.split('\n');
|
||||
const stageIdx = lines.findIndex(l => /^stage\s*:/.test(l));
|
||||
if (stageIdx === -1) return text;
|
||||
|
||||
let stageEnd = lines.length;
|
||||
for (let j = stageIdx + 1; j < lines.length; j++) {
|
||||
if (lines[j] !== '' && !/^\s/.test(lines[j])) { stageEnd = j; break; }
|
||||
}
|
||||
|
||||
const stageLines = lines.slice(stageIdx + 1, stageEnd);
|
||||
const firstIndented = stageLines.find(l => l !== '' && /^\s/.test(l));
|
||||
if (!firstIndented) return text;
|
||||
const voiceIndentLen = firstIndented.match(/^(\s+)/)[1].length;
|
||||
|
||||
// Group stageLines into per-voice entries
|
||||
const entries = [];
|
||||
let cur = null;
|
||||
for (const line of stageLines) {
|
||||
if (line === '') { if (cur) cur.lines.push(line); continue; }
|
||||
const indentLen = (line.match(/^(\s+)/) ?? ['', ''])[1].length;
|
||||
if (indentLen === voiceIndentLen) {
|
||||
if (cur) entries.push(cur);
|
||||
const m = line.match(/^\s+(\w[\w./]*)\s*:(.*)/);
|
||||
cur = m ? { lines: [line], name: m[1], rest: m[2].trim() } : { lines: [line], name: null };
|
||||
} else if (cur) {
|
||||
cur.lines.push(line);
|
||||
}
|
||||
}
|
||||
if (cur) entries.push(cur);
|
||||
|
||||
const keptLines = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.name || entry.name === '_space') { keptLines.push(...entry.lines); continue; }
|
||||
let instrRef = null;
|
||||
if (entry.rest) {
|
||||
const parts = entry.rest.split(/\s+/);
|
||||
if (parts.length >= 3) instrRef = parts[2];
|
||||
} else {
|
||||
for (const l of entry.lines.slice(1)) {
|
||||
const im = l.match(/^\s+instrument\s*:\s*(\S+)/);
|
||||
if (im) { instrRef = im[1]; break; }
|
||||
}
|
||||
}
|
||||
if (instrNames.has(instrRef ?? entry.name)) keptLines.push(...entry.lines);
|
||||
}
|
||||
|
||||
return [...lines.slice(0, stageIdx + 1), ...keptLines, ...lines.slice(stageEnd)].join('\n');
|
||||
}
|
||||
|
||||
function _patchBarMeta(doc, bar) {
|
||||
const props = [];
|
||||
const sp = stressorToString(bar.stressor);
|
||||
if (sp) props.push(` stress_pattern: ${sp}`);
|
||||
if (bar.tempoLevels != null)
|
||||
props.push(` beats_per_minute: ${bar.tempoLevels}`);
|
||||
const ub = _serializeShape(bar.upperStressBound);
|
||||
if (ub) props.push(` upper_stress_bound: ${ub}`);
|
||||
const lb = _serializeShape(bar.lowerStressBound);
|
||||
if (lb) props.push(` lower_stress_bound: ${lb}`);
|
||||
if (bar.tempoShape) { const ts = _serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
|
||||
|
||||
const lines = doc.split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let replaced = false;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (lines[i] === '_meta:') {
|
||||
replaced = true;
|
||||
i++;
|
||||
while (i < lines.length && lines[i].startsWith(' ')) i++;
|
||||
if (props.length) { out.push('_meta:'); out.push(...props); }
|
||||
} else {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!replaced && props.length) {
|
||||
const idIdx = out.findIndex(l => /^_id:/.test(l));
|
||||
if (idIdx !== -1) out.splice(idIdx + 1, 0, '_meta:', ...props);
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function _buildNewBarDoc(bar) {
|
||||
const lines = [`_id: ${bar.id}`, '_meta:'];
|
||||
const sp = stressorToString(bar.stressor);
|
||||
if (sp) lines.push(` stress_pattern: ${sp}`);
|
||||
if (bar.tempoLevels != null) lines.push(` beats_per_minute: ${bar.tempoLevels}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function patchScore(rawScoreText, instruments, bars = [], info = null, articles = []) {
|
||||
const log = [];
|
||||
const SEP = '\n---\n';
|
||||
const [header, ...barDocs] = rawScoreText.split(SEP);
|
||||
|
||||
let patchedHeader = _patchMetadata(header, info);
|
||||
|
||||
if (articles.length) {
|
||||
patchedHeader = _patchArticles(patchedHeader, articles);
|
||||
articles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` }));
|
||||
}
|
||||
|
||||
patchedHeader = _patchInstrumentHeader(patchedHeader, instruments);
|
||||
patchedHeader = _patchStageSection(patchedHeader, instruments);
|
||||
instruments.forEach(i => {
|
||||
if (!(i.isLinked && !i._modified)) log.push({ level: 'changed', path: `instrument / ${i.name}` });
|
||||
});
|
||||
|
||||
const barMap = Object.fromEntries(bars.map(b => [b.id, b]));
|
||||
const newBars = bars.filter(b => b._isNew);
|
||||
|
||||
if (!barDocs.length) {
|
||||
newBars.forEach(b => log.push({ level: 'changed', path: `bar / ${b.id}` }));
|
||||
return { text: [patchedHeader, ...newBars.map(_buildNewBarDoc)].join(SEP), log };
|
||||
}
|
||||
|
||||
const patchedBarDocs = [];
|
||||
barDocs.forEach(doc => {
|
||||
const m = doc.match(/^_id:\s*(\S+)/m);
|
||||
if (!m) { patchedBarDocs.push(doc); return; }
|
||||
const bar = barMap[m[1]];
|
||||
if (!bar) return; // not in model → deleted
|
||||
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
||||
patchedBarDocs.push(_patchBarMeta(doc, bar));
|
||||
});
|
||||
|
||||
newBars.forEach(bar => {
|
||||
patchedBarDocs.push(_buildNewBarDoc(bar));
|
||||
log.push({ level: 'changed', path: `bar / ${bar.id}` });
|
||||
});
|
||||
|
||||
return { text: [patchedHeader, ...patchedBarDocs].join(SEP), log };
|
||||
}
|
||||
|
||||
38
static/node-views.js
Normal file
38
static/node-views.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export 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: [] };
|
||||
case 'motif':
|
||||
return { typeTag: 'motif', label: node.label, meta: node.isStatic ? [{ key: 'static', value: '✓' }] : [] };
|
||||
case 'stem_note':
|
||||
return { typeTag: 'stem_note', label: String(node.pitch), meta: [] };
|
||||
case 'article': {
|
||||
const n = node.properties?.length ?? 0;
|
||||
return { typeTag: 'article', label: node.name, meta: n ? [{ key: 'props', value: n }] : [] };
|
||||
}
|
||||
default:
|
||||
return { typeTag: node.type, label: node.type, meta: [] };
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ export const store = reactive({
|
||||
scoreModel: null,
|
||||
rawScoreText: null,
|
||||
focusPath: [],
|
||||
credentials: null,
|
||||
synthesisStatus: null,
|
||||
isDirty: false,
|
||||
errorMessage: '',
|
||||
exportLog: [],
|
||||
|
||||
setFocus(path) {
|
||||
this.focusPath = path;
|
||||
@@ -25,4 +25,10 @@ export const store = reactive({
|
||||
markDirty() {
|
||||
this.isDirty = true;
|
||||
},
|
||||
|
||||
resetEditState() {
|
||||
this.isDirty = false;
|
||||
this.exportLog = [];
|
||||
this.focusPath = [];
|
||||
},
|
||||
});
|
||||
|
||||
180
static/style.css
180
static/style.css
@@ -247,54 +247,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Import dialog */
|
||||
.se-import-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #555;
|
||||
padding: 1.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
.se-import-dialog h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.se-import-dialog label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.se-import-dialog input {
|
||||
width: 100%;
|
||||
background: #111;
|
||||
border: 1px solid #444;
|
||||
color: #ddd;
|
||||
padding: 0.3rem;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0.8rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.se-import-dialog .se-dialog-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.se-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.se-btn {
|
||||
@@ -355,3 +307,135 @@
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Bar sub-objects: grouped by P?L? key, chips inline per group */
|
||||
.se-bar-groups {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.se-bar-group {
|
||||
padding: 0.15rem 0.1rem;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.se-bar-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.7rem;
|
||||
font-family: monospace;
|
||||
border: 1px solid #383838;
|
||||
background: #1e1e1e;
|
||||
color: #777;
|
||||
margin: 1px;
|
||||
line-height: 1.4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.se-bar-chip.focused {
|
||||
background: #1a3a5a;
|
||||
color: #ddd;
|
||||
border-color: #4a7aaa;
|
||||
}
|
||||
|
||||
.se-bar-chip-label {
|
||||
padding: 0.1rem 0.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.se-bar-chip:hover .se-bar-chip-label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.se-bar-chip-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid #383838;
|
||||
color: #604040;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.se-bar-chip-delete:hover {
|
||||
color: #e06060;
|
||||
}
|
||||
|
||||
.se-bar-add {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-family: monospace;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border: 1px dashed #383838;
|
||||
background: transparent;
|
||||
color: #505050;
|
||||
cursor: pointer;
|
||||
margin: 1px;
|
||||
line-height: 1.4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.se-bar-add:hover {
|
||||
color: #999;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
/* Export log (shown in CP pane after export) */
|
||||
.se-export-log {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
border: 1px solid #333;
|
||||
padding: 0.3rem 0.4rem;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.se-export-changed {
|
||||
color: #aad4aa;
|
||||
}
|
||||
.se-export-info {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.se-prop-key, .se-prop-val {
|
||||
background: #1a1a1a;
|
||||
color: #d8d8d8;
|
||||
border: 1px solid #444;
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.se-prop-key:focus, .se-prop-val:focus {
|
||||
outline: none;
|
||||
border-color: #7a7a7a;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.se-btn-remove {
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: 1px solid #444;
|
||||
border-radius: 0.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.se-btn-remove:hover {
|
||||
color: #e06060;
|
||||
border-color: #602020;
|
||||
}
|
||||
|
||||
.se-btn-delete {
|
||||
background: transparent;
|
||||
color: #e06060;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
.se-btn-delete:hover {
|
||||
color: #ff8080;
|
||||
}
|
||||
|
||||
123
static/subobject-kinds.js
Normal file
123
static/subobject-kinds.js
Normal file
@@ -0,0 +1,123 @@
|
||||
export const KIND_LABEL = {
|
||||
tuning: 'TU',
|
||||
stage: 'ST',
|
||||
instrument: 'IN',
|
||||
articles: 'AR',
|
||||
bar: 'BA',
|
||||
variation: 'VR',
|
||||
label_spec: 'LA',
|
||||
voice: 'VO',
|
||||
motif: 'MO',
|
||||
offset: 'OF',
|
||||
stem_note: 'SN',
|
||||
};
|
||||
|
||||
export function getKindGroups(node) {
|
||||
if (!node) return [];
|
||||
|
||||
if (node.type === 'score') {
|
||||
const groups = [];
|
||||
|
||||
if (node.tuning) {
|
||||
groups.push({ kind: 'tuning', items: [
|
||||
{ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false },
|
||||
]});
|
||||
}
|
||||
|
||||
const stage = [
|
||||
...(node.stageCone ? [{ kind: 'stage', node: node.stageCone, label: 'cone (orchestra)', hasChildren: false }] : []),
|
||||
...(node.stageVoices ?? []).map(sv => ({ kind: 'stage', node: sv, label: sv.name, hasChildren: false })),
|
||||
];
|
||||
if (stage.length) groups.push({ kind: 'stage', items: stage });
|
||||
|
||||
if (node.instruments.length) {
|
||||
groups.push({ kind: 'instrument', items: node.instruments.map(i => ({
|
||||
kind: 'instrument', node: i, label: i.name, hasChildren: !i.isLinked, readOnly: i.isLinked,
|
||||
deletable: true,
|
||||
}))});
|
||||
}
|
||||
|
||||
if ((node.articles ?? []).length) {
|
||||
groups.push({ kind: 'articles', items: node.articles.map(a => {
|
||||
const counts = a.properties.reduce((acc, p) => {
|
||||
acc[p.scope] = (acc[p.scope] ?? 0) + 1; return acc;
|
||||
}, {});
|
||||
const suffix = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(', ');
|
||||
return { kind: 'articles', node: a, label: `${a.name} (${suffix})`, hasChildren: false, deletable: true };
|
||||
})});
|
||||
}
|
||||
|
||||
if (node.bars.length) {
|
||||
groups.push({ kind: 'bar', items: node.bars.map(b => ({
|
||||
kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0,
|
||||
deletable: true,
|
||||
}))});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
if (node.type === 'instrument') {
|
||||
if (!node.variations.length) return [];
|
||||
return [{ kind: 'variation', items: node.variations.map((v, idx) => ({
|
||||
kind: 'variation', node: v,
|
||||
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
|
||||
hasChildren: true, deletable: true,
|
||||
}))}];
|
||||
}
|
||||
|
||||
if (node.type === 'variation') {
|
||||
const groups = [];
|
||||
if (node.labelSpecs.length) {
|
||||
groups.push({ kind: 'label_spec', items: node.labelSpecs.map(ls => ({
|
||||
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false, deletable: true,
|
||||
}))});
|
||||
}
|
||||
if (node.subvariations.length) {
|
||||
groups.push({ kind: 'variation', items: 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, deletable: true };
|
||||
})});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
if (node.type === 'bar') {
|
||||
const entries = Object.entries(node.voices);
|
||||
if (!entries.length) return [];
|
||||
return [{ kind: 'voice', items: entries.map(([name, v]) => ({
|
||||
kind: 'voice', node: v, label: name,
|
||||
hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic),
|
||||
}))}];
|
||||
}
|
||||
|
||||
if (node.type === 'voice') {
|
||||
const groups = [];
|
||||
const staticMotifs = node.motifs.filter(m => m.isStatic);
|
||||
if (staticMotifs.length) {
|
||||
groups.push({ kind: 'motif', items: staticMotifs.map(m => ({
|
||||
kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0,
|
||||
}))});
|
||||
}
|
||||
if (node.offsets.length) {
|
||||
groups.push({ kind: 'offset', items: node.offsets.map((o, idx) => ({
|
||||
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0,
|
||||
}))});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
if (node.type === 'motif' || node.type === 'offset') {
|
||||
if (!node.stemNotes.length) return [];
|
||||
return [{ kind: 'stem_note', items: node.stemNotes.map(sn => ({
|
||||
kind: 'stem_note', node: sn,
|
||||
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
|
||||
hasChildren: false,
|
||||
}))}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
13
static/util.js
Normal file
13
static/util.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export function coerce(s) {
|
||||
if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true;
|
||||
if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false;
|
||||
if (s === '') return s;
|
||||
const n = Number(s);
|
||||
if (!isNaN(n)) return n;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function stressorToString(s) {
|
||||
if (!s?.groups?.length) return '';
|
||||
return s.groups.map(g => g.join(',')).join(';');
|
||||
}
|
||||
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"
|
||||
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": {
|
||||
|
||||
805
test-parser.mjs
Normal file
805
test-parser.mjs
Normal file
@@ -0,0 +1,805 @@
|
||||
#!/usr/bin/env node
|
||||
// Fixture-based compliance test for ast-parser.js + exporter.js
|
||||
// Run: node test-parser.mjs
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { parseAstLog, buildModel } from './static/ast-parser.js';
|
||||
import { exportInstrument, patchScore } from './static/exporter.js';
|
||||
import { stressorToString } from './static/util.js';
|
||||
import { shortView } from './static/node-views.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}`);
|
||||
|
||||
// ── Preamble (info, tuning, stage, articles) ───────────────────────────────
|
||||
section('Preamble');
|
||||
ok('info parsed', model.info && typeof model.info.title === 'string');
|
||||
ok('info composer', model.info?.composer === 'Ludwig van Beethoven');
|
||||
ok('tuning parsed', model.tuning && model.tuning.base === 'tones_euro_de+en');
|
||||
ok('tuning has scales', Object.keys(model.tuning.scales).length > 0);
|
||||
ok('tuning.scales hm7', Array.isArray(model.tuning.scales.hm7));
|
||||
ok('tuning has chords', Object.keys(model.tuning.chords).length > 0);
|
||||
ok('tuning frequencyFactors object', model.tuning.frequencyFactors && typeof model.tuning.frequencyFactors === 'object');
|
||||
ok('frequencyFactors label', model.tuning.frequencyFactors.label === 'just5lim');
|
||||
ok('frequencyFactors 12 ratios', model.tuning.frequencyFactors.factors.length === 12);
|
||||
ok('stageCone parsed', model.stageCone && model.stageCone.type === 'stage_cone');
|
||||
ok('stageCone has minvol', model.stageCone.minvol !== undefined);
|
||||
ok('stageVoices parsed', model.stageVoices.length === 3);
|
||||
ok('stageVoices[0] is "pi"', model.stageVoices[0].name === 'pi');
|
||||
ok('stageVoices[0].direction', model.stageVoices[0].direction === '1|1');
|
||||
ok('articles array non-empty', model.articles.length > 0);
|
||||
ok('articles[0].name "f"', model.articles[0].name === 'f');
|
||||
ok('articles[0].properties[]', Array.isArray(model.articles[0].properties));
|
||||
const fProp = model.articles[0].properties.find(p => p.name === 'add_stress');
|
||||
ok('articles[0] add_stress prop', fProp && fProp.value === 3 && fProp.scope === 'defaults');
|
||||
|
||||
// Article slot structure: stage.article with article.defaults / article.overwrites children
|
||||
const MERGE_FIXTURE = `01 stage.article 'g'
|
||||
02 article.defaults 'add_stress' constant=2
|
||||
02 article.overwrites 'pitch_bend'
|
||||
`;
|
||||
const mergeRoot = parseAstLog('00 tuning base=\'x\'\n' + MERGE_FIXTURE);
|
||||
const mergeModel = buildModel(mergeRoot);
|
||||
ok('merge: single entry per label', mergeModel.articles.length === 1);
|
||||
ok('merge: label "g"', mergeModel.articles[0].name === 'g');
|
||||
ok('merge: 2 properties', mergeModel.articles[0].properties.length === 2);
|
||||
const gDef = mergeModel.articles[0].properties.find(p => p.scope === 'defaults');
|
||||
const gOver = mergeModel.articles[0].properties.find(p => p.scope === 'overwrites');
|
||||
ok('merge: default scope present', gDef && gDef.name === 'add_stress' && gDef.value === 2);
|
||||
ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === null);
|
||||
|
||||
section('article.definite on stem_note');
|
||||
const DEF_ART = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='G5' articulatory='tailed'
|
||||
04 article.definite 'tailed' constant=3
|
||||
04 stem_note.chain _tmp_string='o'
|
||||
05 chain.clause 0
|
||||
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||
`;
|
||||
const defArtModel = buildModel(parseAstLog(DEF_ART));
|
||||
const defSN = defArtModel.bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||
ok('definite prop flattened onto stem_note', defSN.tailed === 3);
|
||||
ok('articulatory not in unknownProps', defSN.unknownProps?.articulatory === undefined);
|
||||
|
||||
section('stem_note weight');
|
||||
const WEIGHT_SN = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='C4' weight=0.8
|
||||
`;
|
||||
const weightModel = buildModel(parseAstLog(WEIGHT_SN));
|
||||
const weightSN = weightModel.bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||
ok('weight parsed', weightSN.weight === 0.8);
|
||||
ok('weight null when absent', defSN.weight === null);
|
||||
|
||||
// ── Bar IDs ────────────────────────────────────────────────────────────────
|
||||
// Bar IDs 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.
|
||||
// All non-linked instruments are always re-serialized; linked+unmodified are passed through.
|
||||
const patchInstruments = model.instruments.map(i => ({ ...i }));
|
||||
const { text: patched, log: patchLog } = 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:'));
|
||||
ok('patchScore log records alpha as changed', patchLog.some(e => e.level === 'changed' && e.path === 'instrument / alpha'));
|
||||
ok('patchScore log records ki as changed', patchLog.some(e => e.path?.includes('ki')));
|
||||
|
||||
// dev/piano is linked and unmodified — it should pass through verbatim
|
||||
const pianoInstr = patchInstruments.find(i => i.name === 'dev/piano');
|
||||
if (pianoInstr) {
|
||||
ok('dev/piano is linked', pianoInstr.isLinked === true);
|
||||
ok('dev/piano not in log (linked+unmodified)', !patchLog.some(e => e.path?.includes('dev/piano')));
|
||||
}
|
||||
|
||||
// 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 (unknownSlots) 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',
|
||||
stressor: { groups: [[2, 3], [1]] },
|
||||
tempoLevels: 140,
|
||||
upperStressBound: null,
|
||||
lowerStressBound: null,
|
||||
tempoShape: null,
|
||||
};
|
||||
const cleanBar = {
|
||||
id: '001P1L1M2',
|
||||
stressor: null, tempoLevels: 100,
|
||||
upperStressBound: null, lowerStressBound: null, tempoShape: null,
|
||||
};
|
||||
const { text: barPatched, log: barLog } = 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 BPM preserved via model', 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);
|
||||
ok('bar log records dirty bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M1'));
|
||||
ok('bar log records clean bar as changed', barLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M2'));
|
||||
|
||||
// Minimal instrument stub reused across patchScore tests that don't care about instrument content.
|
||||
const STUB_ALPHA = { name: 'alpha', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] };
|
||||
const STUB_KI = { name: 'ki', isLinked: false, variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] };
|
||||
|
||||
// ── patchScore metadata ────────────────────────────────────────────────────
|
||||
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 { text: metaPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], 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 present', metaPatched.includes('instrument alpha:'));
|
||||
|
||||
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
|
||||
const { text: noTitlePatched } = patchScore(META_SCORE_NO_TITLE, [STUB_KI], [], { title: 'Fugue', composer: 'Bach' });
|
||||
ok('missing title prepended', noTitlePatched.includes('title: Fugue'));
|
||||
ok('existing composer not duplicated', (noTitlePatched.match(/^composer:/mg) ?? []).length === 1);
|
||||
|
||||
const { text: nullInfoPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], null);
|
||||
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
|
||||
|
||||
// ── patchScore articles ────────────────────────────────────────────────────
|
||||
section('patchScore articles');
|
||||
const ART_SCORE = `articles:\n f: { add_stress: 3 }\n\ninstrument alpha:\n character:\n`;
|
||||
const artModel = [{ name: 'f', properties: [{ name: 'add_stress', value: 4, scope: 'defaults' }] }];
|
||||
const { text: artPatched, log: artLog } = patchScore(ART_SCORE, [STUB_ALPHA], [], null, artModel);
|
||||
ok('article block replaced', artPatched.includes('articles:'));
|
||||
ok('updated value written', artPatched.includes('add_stress: 4'));
|
||||
ok('old flow-style entry removed', !artPatched.includes('{ add_stress: 3 }'));
|
||||
ok('instrument line still present', artPatched.includes('instrument alpha:'));
|
||||
ok('article change logged', artLog.some(e => e.level === 'changed' && e.path === 'articles / f'));
|
||||
|
||||
const ART_SCORE_NO_ART = `instrument alpha:\n character:\n`;
|
||||
const { text: artInserted } = patchScore(ART_SCORE_NO_ART, [STUB_ALPHA], [], null, artModel);
|
||||
ok('article block inserted before instrument when missing', artInserted.indexOf('articles:') < artInserted.indexOf('instrument alpha:'));
|
||||
|
||||
// ── patchScore articles with overwrites ───────────────────────────────────
|
||||
section('patchScore articles with overwrites');
|
||||
const OVW_SCORE = `articles:\n f:\n add_stress: 3\n\ninstrument alpha:\n character:\n`;
|
||||
const ovwModel = [{ name: 'f', properties: [
|
||||
{ name: 'add_stress', value: 4, scope: 'defaults' },
|
||||
{ name: 'add_stress', scope: 'overwrites' },
|
||||
]}];
|
||||
const { text: ovwPatched } = patchScore(OVW_SCORE, [STUB_ALPHA], [], null, ovwModel);
|
||||
ok('overwrites emit -important block', ovwPatched.includes('-important:'));
|
||||
ok('overwrites list entry present', ovwPatched.includes('- add_stress'));
|
||||
|
||||
// ── patchScore deleted bars ────────────────────────────────────────────────
|
||||
section('patchScore deleted bars');
|
||||
const delBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: null, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||
const { text: delBarPatched } = patchScore(RAW_SCORE_WITH_BARS, [], [delBar2]);
|
||||
ok('deleted bar removed from output', !delBarPatched.includes('_id: 001P1L1M1'));
|
||||
ok('non-deleted bar preserved', delBarPatched.includes('_id: 001P1L1M2'));
|
||||
ok('deleted bar reduces document count', (delBarPatched.match(/\n---\n/g) ?? []).length === 1);
|
||||
|
||||
// ── patchScore new bars ────────────────────────────────────────────────────
|
||||
section('patchScore new bars');
|
||||
const existingBar1 = { id: '001P1L1M1', stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||
const existingBar2 = { id: '001P1L1M2', stressor: null, tempoLevels: 100, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||
const newBarEntry = { id: '001P1L1M3', _isNew: true, stressor: null, tempoLevels: 120, upperStressBound: null, lowerStressBound: null, tempoShape: null };
|
||||
const { text: newBarPatched, log: newBarLog } = patchScore(RAW_SCORE_WITH_BARS, [], [existingBar1, existingBar2, newBarEntry]);
|
||||
ok('new bar appended to output', newBarPatched.includes('_id: 001P1L1M3'));
|
||||
ok('new bar has _meta with BPM', newBarPatched.includes('beats_per_minute: 120'));
|
||||
ok('new bar appended as extra document', (newBarPatched.match(/\n---\n/g) ?? []).length === 3);
|
||||
ok('new bar logged as changed', newBarLog.some(e => e.level === 'changed' && e.path === 'bar / 001P1L1M3'));
|
||||
|
||||
// ── NOT_CHANGED_SINCE epoch → ISO conversion ──────────────────────────────
|
||||
section('NOT_CHANGED_SINCE: epoch converted on passthrough, now on re-serialize');
|
||||
const ncsScore = `instrument linked/pi:\n NOT_CHANGED_SINCE: 1710000000.0\n character:\n A: "1:0,10;1,0"\n\ninstrument embedded:\n NOT_CHANGED_SINCE: 1710000000.0\n character:\n A: "1:0,5;1,0"\n`;
|
||||
const ncsList = [
|
||||
{ name: 'linked/pi', isLinked: true, _modified: false, notChangedSince: 1710000000.0,
|
||||
variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||
{ name: 'embedded', isLinked: false, _modified: true, notChangedSince: 1710000000.0,
|
||||
variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||
];
|
||||
const { text: ncsPatched } = patchScore(ncsScore, ncsList);
|
||||
const ISO_RX = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/;
|
||||
const ncsLines = ncsPatched.split('\n');
|
||||
const linkedNCS = ncsLines.find(l => /NOT_CHANGED_SINCE/.test(l) && ncsLines.indexOf(l) < ncsLines.findIndex(l2 => /instrument embedded/.test(l2)));
|
||||
const embeddedNCS = ncsLines.find((l, i) => /NOT_CHANGED_SINCE/.test(l) && i > ncsLines.findIndex(l2 => /instrument embedded/.test(l2)));
|
||||
ok('linked passthrough: epoch converted to ISO date', linkedNCS && ISO_RX.test(linkedNCS) && !linkedNCS.includes('1710000000'));
|
||||
ok('modified instrument: NOT_CHANGED_SINCE set to now', embeddedNCS && ISO_RX.test(embeddedNCS) && !embeddedNCS.includes('1710000000'));
|
||||
|
||||
// ── patchScore deleted instrument ─────────────────────────────────────────
|
||||
section('patchScore deleted instrument');
|
||||
const delInstrScore = `instrument alpha:\n character:\n A: "1:0,10;1,0"\n\ninstrument ki:\n character:\n A: "1:0,5;1,0"\n`;
|
||||
const delInstrList = [
|
||||
{ name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||
];
|
||||
const { text: delInstrPatched, log: delInstrLog } = patchScore(delInstrScore, delInstrList);
|
||||
ok('deleted instrument removed from output', !delInstrPatched.includes('instrument alpha:'));
|
||||
ok('non-deleted instrument preserved', delInstrPatched.includes('instrument ki:'));
|
||||
ok('ki re-serialized and logged', delInstrLog.some(e => e.level === 'changed' && e.path?.includes('ki')));
|
||||
|
||||
// ── patchScore stage section cleanup ─────────────────────────────────────
|
||||
section('patchScore stage section: deleted instrument removes voice entry');
|
||||
// pi → mapping form, instrument: dev/piano (present) → keep
|
||||
// ki → mapping form, instrument: alpha (deleted) → remove
|
||||
// alpha → string form, implicit name=alpha (deleted) → remove
|
||||
const stageScore = [
|
||||
'stage:',
|
||||
' pi:',
|
||||
' direction: 1|1',
|
||||
' distance: 0',
|
||||
' instrument: dev/piano',
|
||||
' ki:',
|
||||
' direction: 2|1',
|
||||
' distance: 1',
|
||||
' instrument: alpha',
|
||||
' alpha: 2|3 0',
|
||||
'',
|
||||
'instrument dev/piano:',
|
||||
' character:',
|
||||
' A: "1:0,10;1,0"',
|
||||
].join('\n');
|
||||
const stageInstrList = [
|
||||
{ name: 'dev/piano', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||
];
|
||||
const { text: stagePatched } = patchScore(stageScore, stageInstrList);
|
||||
ok('voice with explicit deleted instrument (mapping form) removed', !stagePatched.includes(' ki:'));
|
||||
ok('voice with implicit deleted name (string form) removed', !stagePatched.includes(' alpha:'));
|
||||
ok('voice with explicit surviving instrument (mapping form) kept', stagePatched.includes(' pi:'));
|
||||
ok('instrument dev/piano block re-serialized', stagePatched.includes('instrument dev/piano:'));
|
||||
|
||||
const stageScoreKeep = [
|
||||
'stage:',
|
||||
' pi:',
|
||||
' direction: 1|1',
|
||||
' distance: 0',
|
||||
' instrument: dev/piano',
|
||||
' ki: 2|1 1',
|
||||
].join('\n');
|
||||
const stageInstrKeep = [
|
||||
{ name: 'dev/piano', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||
{ name: 'ki', variations: [], basicProperties: null, volumes: null, timbre: null, fmModulations: [], amModulations: [] },
|
||||
];
|
||||
const { text: stageKeptPatched } = patchScore(stageScoreKeep, stageInstrKeep);
|
||||
ok('retained voice with explicit instrument key kept', stageKeptPatched.includes(' pi:'));
|
||||
ok('retained voice with implicit instrument name kept', stageKeptPatched.includes(' ki:'));
|
||||
|
||||
// ── Motif parsing ─────────────────────────────────────────────────────────
|
||||
section('Motif parsing — dynamic motif');
|
||||
const DYN_MOTIF = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.motif label='oct'
|
||||
03 line.stem_note pitch=0
|
||||
04 stem_note.chain _tmp_string='oo'
|
||||
05 chain.clause 0 repeat=1
|
||||
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||
06 seq.note letters='o' shift=12 netlength=1 length=1
|
||||
`;
|
||||
const dynBar = buildModel(parseAstLog(DYN_MOTIF)).bars[0];
|
||||
const dynVoice = dynBar.voices['pi'];
|
||||
ok('motif is object', typeof dynVoice.motifs[0] === 'object');
|
||||
ok('motif label', dynVoice.motifs[0].label === 'oct');
|
||||
ok('motif pitch=0', dynVoice.motifs[0].stemNotes[0].pitch === 0);
|
||||
ok('dynamic motif isStatic=false', dynVoice.motifs[0].isStatic === false);
|
||||
ok('dynamic motif has 1 clause', dynVoice.motifs[0].stemNotes[0].clauses.length === 1);
|
||||
ok('clause has 2 notes', dynVoice.motifs[0].stemNotes[0].clauses[0].notes.length === 2);
|
||||
|
||||
section('Motif parsing — static motif');
|
||||
const STAT_MOTIF = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.motif label='cadence'
|
||||
03 line.stem_note pitch='C4'
|
||||
04 stem_note.chain _tmp_string='o'
|
||||
05 chain.clause 0
|
||||
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||
`;
|
||||
const statVoice = buildModel(parseAstLog(STAT_MOTIF)).bars[0].voices['pi'];
|
||||
ok('static motif isStatic=true', statVoice.motifs[0].isStatic === true);
|
||||
ok('static motif pitch string', statVoice.motifs[0].stemNotes[0].pitch === 'C4');
|
||||
|
||||
section('Motif parsing — pause and stack in clause');
|
||||
const PS_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='C4'
|
||||
04 stem_note.chain _tmp_string='o.oe'
|
||||
05 chain.clause 0
|
||||
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||
06 seq.pause length=1
|
||||
05 chain.clause 1
|
||||
06 seq.stack length=2 netlength=2
|
||||
07 stack.note letters='o' shift=0
|
||||
07 stack.note letters='e' shift=0
|
||||
`;
|
||||
const psVoice = buildModel(parseAstLog(PS_FIXTURE)).bars[0].voices['pi'];
|
||||
const psSn = psVoice.offsets[0].stemNotes[0];
|
||||
ok('two clauses', psSn.clauses.length === 2);
|
||||
ok('first clause has 1 note + 1 pause', psSn.clauses[0].notes.length === 1 && psSn.clauses[0].pauses.length === 1);
|
||||
ok('second clause has 1 stack', psSn.clauses[1].stacks.length === 1);
|
||||
ok('stack has 2 notes', psSn.clauses[1].stacks[0].notes.length === 2);
|
||||
|
||||
section('Motif parsing — nested chain ignored gracefully');
|
||||
const NC_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='C4'
|
||||
04 stem_note.chain _tmp_string='o(...)'
|
||||
05 chain.clause 0
|
||||
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||
06 seq.chain length=2
|
||||
07 chain.clause 0
|
||||
08 seq.stack length=1 netlength=1
|
||||
09 stack.note letters='o' shift=0
|
||||
`;
|
||||
const ncSn = buildModel(parseAstLog(NC_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||
ok('nested chain: only 1 top-level clause', ncSn.clauses.length === 1);
|
||||
ok('nested chain: only direct note counted', ncSn.clauses[0].notes.length === 1);
|
||||
ok('nested chain: no stacks leak from nested chain', ncSn.clauses[0].stacks.length === 0);
|
||||
|
||||
section('Depth-jump guard (abort)');
|
||||
const DJ_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.motif label='oct'
|
||||
03 line.stem_note pitch='C4'
|
||||
07 chain.clause 0
|
||||
`;
|
||||
let djThrew = false;
|
||||
try { parseAstLog(DJ_FIXTURE); } catch (e) { djThrew = true; }
|
||||
ok('depth jump throws', djThrew);
|
||||
|
||||
section('No-slot guard (abort)');
|
||||
const NS_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 voice
|
||||
`;
|
||||
let nsThrew = false;
|
||||
try { parseAstLog(NS_FIXTURE); } catch (e) { nsThrew = true; }
|
||||
ok('missing slot throws', nsThrew);
|
||||
|
||||
section('line.motif invocation at offset');
|
||||
const LM_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='C4'
|
||||
04 stem_note.chain _tmp_string='o'
|
||||
05 chain.clause 0
|
||||
06 seq.note letters='o' shift=0 length=1 netlength=1
|
||||
03 line.motif 'coct'
|
||||
03 line.motif 'oct' chord='C2'
|
||||
`;
|
||||
const lmOffset = buildModel(parseAstLog(LM_FIXTURE)).bars[0].voices['pi'].offsets[0];
|
||||
ok('line.motif: offset has 1 stem note', lmOffset.stemNotes.length === 1);
|
||||
ok('line.motif: offset.motifRefs has 2 entries', lmOffset.motifRefs.length === 2);
|
||||
ok('line.motif: first motifRef label', lmOffset.motifRefs[0].label === 'coct');
|
||||
ok('line.motif: second motifRef label', lmOffset.motifRefs[1].label === 'oct');
|
||||
ok('line.motif: second motifRef chord', lmOffset.motifRefs[1].chord === 'C2');
|
||||
ok('line.motif: first motifRef chord null', lmOffset.motifRefs[0].chord === null);
|
||||
|
||||
section('stem_note.write_to and chainText');
|
||||
const WT_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='C2'
|
||||
04 stem_note.write_to 'coct'
|
||||
04 stem_note.chain _tmp_string='o=o+3*4'
|
||||
05 chain.clause 0 repeat=3
|
||||
06 seq.note letters='o' shift=0 netlength=1 length=1
|
||||
06 seq.note letters='o:f' shift=3 netlength=1 length=1
|
||||
`;
|
||||
const wtSn = buildModel(parseAstLog(WT_FIXTURE)).bars[0].voices['pi'].offsets[0].stemNotes[0];
|
||||
ok('write_to: writeToName', wtSn.writeToName === 'coct');
|
||||
ok('write_to: chainText from _tmp_string', wtSn.chainText === 'o=o+3*4');
|
||||
ok('write_to: clause parsed', wtSn.clauses.length === 1);
|
||||
ok('write_to: clause repeat', wtSn.clauses[0].repeat === 3);
|
||||
ok('write_to: clause notes', wtSn.clauses[0].notes.length === 2);
|
||||
ok('seq.note has letters field (not letter)', wtSn.clauses[0].notes[0].letters === 'o');
|
||||
ok('seq.note letters with article ref', wtSn.clauses[0].notes[1].letters === 'o:f');
|
||||
|
||||
section('adjacent prop on stem_note');
|
||||
const ADJ_FIXTURE = `00 bar '001P1L1M1'
|
||||
01 bar.voice 'pi'
|
||||
02 voice.offset tick=0
|
||||
03 line.stem_note pitch='C4'
|
||||
03 line.stem_note pitch='D4' adjacent=False
|
||||
03 line.stem_note pitch='E4' adjacent=True
|
||||
`;
|
||||
const adjOffset = buildModel(parseAstLog(ADJ_FIXTURE)).bars[0].voices['pi'].offsets[0];
|
||||
ok('adjacent: null when absent', adjOffset.stemNotes[0].adjacent === null);
|
||||
ok('adjacent: false when False', adjOffset.stemNotes[1].adjacent === false);
|
||||
ok('adjacent: true when True', adjOffset.stemNotes[2].adjacent === true);
|
||||
|
||||
section('shortView');
|
||||
ok('shortView null → ?', shortView(null).typeTag === '?');
|
||||
ok('shortView score', shortView(model).typeTag === 'score');
|
||||
ok('shortView score label', shortView(model).label === model.info?.title);
|
||||
ok('shortView score meta', shortView(model).meta.some(m => m.key === 'composer'));
|
||||
ok('shortView instrument', shortView(model.instruments[0]).typeTag === 'instrument');
|
||||
ok('shortView instrument label',shortView(model.instruments[0]).label === model.instruments[0].name);
|
||||
ok('shortView bar', shortView(model.bars[0]).typeTag === 'bar');
|
||||
ok('shortView bar label', shortView(model.bars[0]).label === model.bars[0].id);
|
||||
ok('shortView article', shortView(model.articles[0]).typeTag === 'article');
|
||||
const svV = model.instruments[0]?.variations[0];
|
||||
if (svV) {
|
||||
ok('shortView variation root', shortView(svV).label === '(root variation)' || shortView(svV).label.startsWith('ATTR'));
|
||||
}
|
||||
|
||||
section('unknownSlots graceful degradation');
|
||||
const UNK_FIXTURE = `00 instrument 'test'
|
||||
01 character.FUTURE_SLOT foo='bar'
|
||||
01 character.variation
|
||||
02 variation.ANOTHER_UNKNOWN x=1
|
||||
`;
|
||||
const unkModel = buildModel(parseAstLog(UNK_FIXTURE));
|
||||
const unkInstr = unkModel.instruments[0];
|
||||
ok('unknown slot: instrument parsed', !!unkInstr);
|
||||
ok('unknown slot: unknownSlots is array', Array.isArray(unkInstr.unknownSlots));
|
||||
ok('unknown slot: instrument-level unknown collected', unkInstr.unknownSlots.length === 1);
|
||||
ok('unknown slot: collected slot type', unkInstr.unknownSlots[0].type === 'FUTURE_SLOT');
|
||||
ok('variation-level unknown collected', unkInstr.variations[0]?.unknownSlots?.length === 1);
|
||||
|
||||
section('Full fixture integration');
|
||||
ok('fixture parses without error (checked above)', raw && raw.slot === 'root');
|
||||
ok('fixture has 432 bars', model.bars.length === 432);
|
||||
const voicesWithMotifs = model.bars.flatMap(b => Object.values(b.voices)).filter(v => v.motifs.length > 0);
|
||||
ok('fixture voices have motif objects', voicesWithMotifs.every(v => v.motifs.every(m => typeof m === 'object' && 'label' in m)));
|
||||
const offsetsWithStemNotes = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.stemNotes.length > 0);
|
||||
ok('fixture offsets have stem note objects', offsetsWithStemNotes.every(o => o.stemNotes.every(sn => 'pitch' in sn && 'clauses' in sn)));
|
||||
const offsetsWithMotifInvocations = model.bars.flatMap(b => Object.values(b.voices)).flatMap(v => v.offsets).filter(o => o.motifRefs?.length > 0);
|
||||
ok('fixture offset motif invocations are objects', offsetsWithMotifInvocations.every(o => o.motifRefs.every(m => 'label' in m)));
|
||||
const stemNotesWithChainText = offsetsWithStemNotes.flatMap(o => o.stemNotes).filter(sn => sn.chainText);
|
||||
ok('fixture stem notes with chain text have clauses', stemNotesWithChainText.every(sn => sn.clauses.length > 0));
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user