Compare commits

36 Commits

Author SHA1 Message Date
c0dev0id
19c89711be exporter: remove stage voice entries for deleted instruments 2026-07-13 21:16:02 +02:00
c0dev0id
f18f07dc22 PaneCP: show synthesis errors from status.json when frozen 2026-07-13 20:49:03 +02:00
c0dev0id
d8e6e7a02c exporter: convert NOT_CHANGED_SINCE epoch to ISO on passthrough; set to now on re-serialize 2026-07-13 20:36:47 +02:00
c0dev0id
d79599ede1 test: update seq.note fixtures letter= -> letters=; assert multi-ref form 2026-07-11 14:22:31 +02:00
c0dev0id
7ffa0a9d2d phases 4-6: article system, stem note fields, edit-state cleanup
Phase 4 — remove edit-state flags from domain objects:
- ast-parser.js: drop isDirty from buildInstrument, buildBar, buildStemNote
- store.js: add resetEditState()
- PaneCP.js: use resetEditState() on import; drop flags arg from patchScore
- PaneFO.js: isDirty -> _modified for linked instruments; no per-node dirty tracking
- PaneSubObjects.js: _spliceNode helper; deletion via splice; _isNew on new bars
- exporter.js: on-demand traversal; absence from model means deleted

Phase 5 — article parser and exporter:
- ast-parser.js: _buildArticleEntry replaces _mergeArticleEntry; stage.article and
  voice.article dispatch; article properties carry scope and overwritten flag
- exporter.js: _buildArticlesBlock emits -important: list for overwrites scope

Phase 6 — stem note dead fields:
- ast-parser.js: parse weight and articulatory on stem notes; flatten
  article.definite properties onto stem note model sibling to pitch/chain
2026-07-11 12:07:05 +02:00
Florian "flowdy" Heß
e2b7af77d6 Fixture AST.log generated by Sompyler rev. 338faf6 2026-07-11 10:35:32 +02:00
Florian "flowdy" Heß
fc31c40fc3 AST.log fixture reflects Sompyler reworked articulation rev. b4da5820 2026-07-09 21:42:40 +02:00
c0dev0id
e908bd4768 ast-parser: simplify _parseRest regex to 2 capture groups 2026-07-08 20:29:35 +02:00
c0dev0id
a0104214b7 ast-parser: replace manual char scan in _parseRest with regex tokenizer 2026-07-08 18:22:12 +02:00
c0dev0id
bf35441b77 refactor: functional style in exporter.js and subobject-kinds.js 2026-07-06 21:20:32 +02:00
c0dev0id
d3a55cb663 refactor: remove WHAT-comments, keep WHY and RFC references 2026-07-06 21:17:46 +02:00
c0dev0id
884955195a refactor: underscore-prefix all non-exported functions 2026-07-06 21:14:36 +02:00
c0dev0id
3beaa1d2ec refactor: rename Offset.motifs to motifRefs 2026-07-06 21:11:55 +02:00
c0dev0id
7fb584d929 refactor: rename rawChildren to unknownSlots 2026-07-06 21:10:46 +02:00
c0dev0id
e823608325 refactor: extract shortView into node-views.js 2026-07-06 21:10:28 +02:00
c0dev0id
9a2e76ba4f refactor: extract coerce and stressorToString into util.js 2026-07-06 21:09:36 +02:00
c0dev0id
1ad4b73c9a test: add coverage for unknownSlots graceful degradation 2026-07-06 21:08:40 +02:00
Florian "flowdy" Heß
41b17d4bb0 fixtures/ast.log generated by Sompyler 0ca9634. 2026-06-30 21:47:58 +02:00
c0dev0id
66c1f2c151 Add delete buttons and bar add/create to sub-objects pane 2026-06-30 13:18:29 +02:00
c0dev0id
e011c9e7d8 barGroupKey: strip final same-class char run, no ID parsing 2026-06-30 12:19:14 +02:00
c0dev0id
89f46e7579 BA pane: group bars by P?L? key, render as inline chips
Bars in the BA sub-objects pane are now visually grouped by the
P\d+L\d+ fragment extracted from their opaque IDs (e.g. P1L1, P2L3).
Each group renders as a row of compact inline-block chips; groups are
separated by a line break. Fallback: if an ID contains no P?L?
pattern, trailing digits are stripped instead, yielding singleton rows.

The grouping is a display-only heuristic — no semantic meaning is
derived from the ID. Chips are clickable, highlight when focused.
2026-06-30 12:06:05 +02:00
c0dev0id
67c75781d8 Drop custom auth dialog; rely on browser Basic Auth handling
Remove ImportDialog and all manual credential management. The browser
handles 401 challenges natively for same-origin fetch requests — it
shows its own credential prompt, retries, and caches the result for
the session. No JS-level auth code is needed.

- api.js: remove authHeader() and all credentials parameters
- store.js: remove credentials field
- ImportDialog.js: deleted
- AppShell.js: remove showImport/openImport/onMounted dialog machinery;
  pass importOnLoad through to PaneCP
- PaneCP.js: inline doImport() (was in ImportDialog); auto-triggers on
  mount when importOnLoad is true; shows inline import error on failure
- StatusPoller.js: remove credentials arg from fetchStatus()
- style.css: remove .se-import-dialog and .se-overlay blocks
2026-06-30 09:32:44 +02:00
c0dev0id
86b14dfaa3 Article export + Bootstrap toggle + export change log
- patchScore() now accepts articles[] and returns { text, log } instead
  of a plain string. Dirty articles are re-serialized into the articles:
  block (block style, replacing any existing flow-style entries). Clean
  articles bypass the patcher entirely.
- article.isDirty is set on direct property edits so the exporter can
  distinguish directly changed entities from propagated dirty state.
- patchScore() log collects { level:'changed', path } for each patched
  article/instrument/bar, and a { level:'info', message } entry for the
  count of bar documents passed through unchanged.
- CP pane shows the export log above the status poller after each export.
- Article scope toggle replaced with Bootstrap form-check form-switch;
  hand-rolled .se-toggle CSS removed.
- Test suite updated for { text, log } return; 11 new assertions (188 total).
2026-06-30 06:27:12 +02:00
c0dev0id
5bec1a1d1e PaneCP article label + PaneFO editable property rows
Article entries in the CP path list now show the article label
(letter/extension) instead of just "article", with a property count
in the meta column. In the FO pane, each property is rendered as an
editable row: key input, value input (coerced via ast-parser.coerce),
scope toggle, morphing default/overwrite label, and per-row remove
button. A "+ add property" button appends a blank defaults entry.
Every edit marks the score dirty.

`coerce` is now exported from ast-parser.js so the FO pane can apply
the same type-inference rules to user input as the parser does to
AST values.
2026-06-29 08:17:57 +02:00
c0dev0id
b822e4d919 Article toggle: single morphing label (default/overwrite) instead of two flanking labels 2026-06-28 21:39:33 +02:00
c0dev0id
a91b5747a3 Article scope: render an actual sliding toggle, not an ASCII-labelled button
Previous commit took the user's (O-) / (-O) shorthand literally and put
those glyphs as the button label. They were describing the visual states
of a real toggle switch, not text to display.

Now: pill-shaped track with a sliding circular thumb, "default" and
"overwrite" labels flanking it (the active side brightens). The button
carries role="switch" + aria-checked for accessibility. Click anywhere
on the track to flip and mark the score dirty.
2026-06-28 20:37:53 +02:00
c0dev0id
133117922a Articles: key by label across subtypes, scope per property, FO toggle widget
A single article label (e.g. 'f') may emit on multiple `articles.<subtype>`
lines — `defaults` today, `overwrites` planned — with disjoint property
sets that apply at different stages. Model now merges those emissions:

  { name: 'f', properties: [{ name, value, scope: 'defaults'|'overwrites' }] }

Per-label entries in the AR pane show a property-count breakdown
(`f (1 defaults, 2 overwrites)`). The Article FO pane lists each property
with a `(O-) default` / `(-O) overwrite` toggle — click flips the scope
and marks the score dirty. ASCII glyph mimics a physical switch position
so the active scope is visible at a glance.

Fixture tests cover both the existing single-subtype shape and a synthetic
two-subtype merge (177 assertions total).
2026-06-28 19:11:01 +02:00
c0dev0id
913114bb02 Multi-pane Sub-objects driven by SLOT of SLOT.SUBTYPE
The bottom handle bar is now dynamic. Fixed CP + FO handles, then one
handle per kind of sub-object the focused node has. "Kind" is the SLOT
part of the AST's SLOT.SUBTYPE namespace: sub-objects sharing a SLOT
(e.g. `line.stem_note` + `line.motif`, or `articles.<subtypeA>` +
`articles.<subtypeB>`) collapse into one pane; different SLOTs split.

Score-level kinds emit, in this order: TU (tuning), ST (stage cone +
voices), IN (instruments — linked ones listed read-only), AR (articles),
BA (bars). info metadata is folded into the FO pane (was previously
listed as a sub-object).

Handles are 2-letter labels for now, intended as alt-text for icons in
a later release.

- `subobject-kinds.js` new: `getKindGroups(node)` returns ordered
  `[{ kind, items }]`; `KIND_LABEL` maps kind -> 2-letter label.
- `PaneSubObjects.js` now parameterized by `kind` prop; renders one group.
- `AppShell.js` builds the handle bar from `getKindGroups(focusedNode)`;
  drops back to FO when the active sub-pane disappears after a focus change.
- `ObjectShort.js` accepts a `readOnly` flag (used for linked instruments).
2026-06-28 15:42:08 +02:00
c0dev0id
b23e243225 Parser: support sompyler 10cad1f preamble (stage.*, articles.<subtype>, tuning.frequency_factors)
- Top-level dispatch switches on fqSlot (parentSlot.slot) so dotted top-level
  slots (`stage.cone`, `stage.voice`, `articles.<subtype>`) are matched.
- `stage_voice` -> `stage.cone` (new, one per score) + `stage.voice` (list).
- `article` -> `articles.<subtype>` (subtype stored on the article entry;
  first observed subtype is `defaults`).
- New `tuning.frequency_factors` child captured as `{ label, factors }`.
- buildModel un-nests preamble siblings that sompyler emits at depth 01
  under `00 tuning` via `with deeper_level("articles")` / `deeper_level("stage")`.
  Only `tuning`'s children are flattened; `instrument`'s implicit containers
  (`character`, `VOLUMES`, `TIMBRE`, `FM`, `AM`) are left untouched.
- Fixture test extended with 18 preamble assertions (172 total now pass).
2026-06-28 15:41:54 +02:00
Florian "flowdy" Heß
330b3788f0 New copy fixtures/ast.log according to Sompyler 10cad1fc2 2026-06-28 13:56:30 +02:00
c0dev0id
6be4a3297a Revert "Support both old and new sompyler AST slot names in parser"
This reverts commit b56d84befb.
2026-06-28 01:53:53 +02:00
c0dev0id
b56d84befb Support both old and new sompyler AST slot names in parser
Pre-c3b51bc sompyler emits offset.stem_note/motif.stem_note/clause.note
while new sompyler emits line.stem_note/seq.note. Parser now accepts both
parentSlot values for stem_note, motif, and clause children so the app
works against both old and updated sompyler installations.

Also falls back to reading chain.clause directly from stem_note's children
when no stem_note.chain node is present (old format had no chain wrapper).
2026-06-27 20:08:31 +02:00
c0dev0id
859e62e143 Adapt parser and tests to new sompyler AST slot names
Sompyler commit c3b51bc renamed slots across the board:
- offset.stem_note → line.stem_note (parentSlot 'offset' → 'line')
- motif.stem_note → line.stem_note (parentSlot 'motif' → 'line')
- clause.note/pause/stack → seq.note/pause/stack (parentSlot 'clause' → 'seq')
- stem_note.chain is now a real hierarchical node (depth 04) between
  line.stem_note and chain.clause; clauses are now its children, not
  direct children of stem_note

Also adds:
- offset.motifs[] for line.motif invocations at tick positions
- stem_note.writeToName from stem_note.write_to positional
- adjacent prop already stored; new tests verify True/False/absent

PaneFO.js: show offset motif invocations inline (label + chord)
test-parser.mjs: update synthetic fixtures to new depths/slot names;
  add tests for line.motif, stem_note.write_to, adjacent prop
2026-06-27 18:03:14 +02:00
c0dev0id
7fd29ef9b4 Merge commit '94758243f380ed9c848d18d67b257a510563b126' 2026-06-27 17:23:26 +02:00
c0dev0id
b887341df4 Add motif/stem_note display and offset note hierarchy
- parseAstLog: abort on depth jump >1 or missing slot (depth>0)
- buildStemNote: full clause tree (notes/pauses/stacks), editable fields
- buildClause: replaces buildCluster; filters nested clause.chain silently
- buildMotif: builds from voice.motif; sets isStatic
- buildOffset: uses buildStemNote; drops old clusters/chains arrays
- buildVoice: motifs are now objects, not label strings
- PaneSubObjects: static motifs before offsets; motif/offset→stem_note drill-down
- PaneFO: motif + stem_note FO cases; unknownProps display helper
- PaneCP: motif + stem_note shortView cases
- test-parser.mjs: 21 new synthetic tests for motif/clause/stack/guards
2026-06-27 16:14:56 +02:00
Florian "flowdy" Heß
94758243f3 fixtures/ast.log produced by Sompyler commit c3b51bc 2026-06-27 15:41:39 +02:00
18 changed files with 11465 additions and 8630 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,45 +2,30 @@ const U = window.NEUSICIAN_URLS;
export const URLS = U;
function authHeader(credentials) {
if (!credentials) return {};
const b64 = btoa(`${credentials.username}:${credentials.password}`);
return { Authorization: `Basic ${b64}` };
}
export async function fetchAstLog(credentials) {
const res = await fetch(U.astlog, {
headers: authHeader(credentials),
});
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(U.score, {
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) {
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(U.status, {
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();
}

View File

@@ -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)) 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,95 +71,139 @@ 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,
volumes: null,
timbre: null,
fmModulations: [],
amModulations: [],
rawChildren: [],
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);
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 });
@@ -206,14 +212,14 @@ function buildInstrument(node) {
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 ?? node.props.for_value ?? null,
@@ -222,85 +228,85 @@ function buildVariation(node) {
subvariations: [],
spread: null,
railsbackCurve: null,
rawChildren: [],
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);
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: [],
amModulations: [],
rawChildren: [],
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') {
const fm = { ...child.props };
const envChild = child.children.find(c => c.slot === 'shape');
if (envChild) fm.shape = buildShape(envChild);
if (envChild) fm.shape = _buildShape(envChild);
bp.fmModulations.push(fm);
} 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);
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') ||
@@ -308,17 +314,17 @@ function buildLabelSpec(node) {
) {
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 });
ls.basicProperties = _buildBasicProperties({ children: directBpChildren });
}
return ls;
}
function buildShape(node) {
function _buildShape(node) {
return {
type: 'shape',
length: node.props.length,
@@ -334,11 +340,10 @@ function buildShape(node) {
};
}
function buildBar(node) {
function _buildBar(node) {
const bar = {
type: 'bar',
id: node.positionals[0] ?? '',
isDirty: false,
stressor: null,
tempoShape: null,
tempoLevels: null,
@@ -346,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) {
@@ -396,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],
@@ -409,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
@@ -425,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),
};
}

View File

@@ -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, onFocusFO: () => { activePane.value = 'fo'; } })),
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, onFocusFO: () => { activePane.value = 'fo'; } })),
...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,
]);
};
},

View File

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

View File

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

View File

@@ -1,16 +1,22 @@
import { h } from 'vue';
// One-line summary row with a drill-down chevron.
export const ObjectShort = {
props: ['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',

View File

@@ -1,43 +1,15 @@
import { h, ref, watch } from 'vue';
import { fetchScoreText, putScoreText, fetchAudioWidget, URLS } 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';
// Short label + identifying meta for each node type.
function shortView(node) {
if (!node) return { typeTag: '?', label: '?', meta: [] };
switch (node.type) {
case 'score':
return {
typeTag: 'score',
label: node.info?.title ?? '(untitled)',
meta: node.info?.composer ? [{ key: 'composer', value: node.info.composer }] : [],
};
case 'instrument':
return { typeTag: 'instrument', label: node.name, meta: [] };
case 'variation': {
const dep = node.dependsOn;
const label = dep == null ? '(root variation)'
: isNaN(Number(dep)) ? `ATTR: ${dep}`
: String(dep);
return { typeTag: 'variation', label, meta: [] };
}
case 'label_spec':
return { typeTag: 'label', label: node.label ?? '(no label)', meta: [] };
case 'bar':
return { typeTag: 'bar', label: node.id, meta: [] };
case 'voice':
return { typeTag: 'voice', label: node.name, meta: [] };
case 'offset':
return { typeTag: 'tick', label: String(node.tick ?? '?'), meta: [] };
default:
return { typeTag: node.type, label: node.type, meta: [] };
}
}
export const PaneCP = {
props: ['store', 'onImportClick', 'onFocusFO'],
props: ['store', 'importOnLoad', 'onFocusFO'],
setup(props) {
const importing = ref(false);
const importError = ref('');
const exporting = ref(false);
const exportError = ref('');
const audioWidgetHtml = ref('');
@@ -53,14 +25,33 @@ export const PaneCP = {
},
);
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, props.store.scoreModel.bars, props.store.scoreModel.info);
await putScoreText(patched, props.store.credentials);
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;
@@ -69,6 +60,8 @@ export const PaneCP = {
}
}
onMounted(() => { if (props.importOnLoad) doImport(); });
return () => {
const store = props.store;
const model = store.scoreModel;
@@ -85,10 +78,10 @@ export const PaneCP = {
h('div', { class: 'se-cp-header' }, [
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', {
@@ -123,8 +116,23 @@ export const PaneCP = {
})
) : 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,
// 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,
// Status poller (while running)
store.synthesisStatus && !store.synthesisStatus.frozen

View File

@@ -3,18 +3,23 @@ import { ObjectExtended } from './ObjectExtended.js';
import { EnvelopeEditor } from './EnvelopeEditor.js';
import { ShapeEditor } from './ShapeEditor.js';
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
import { stressorToString } from '../exporter.js';
import { coerce, stressorToString } from '../util.js';
const H4 = { style: 'margin:0 0 0.5rem' };
function parseStressor(str) {
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) {
function _scoreInfoFields(info) {
return [
{ key: 'title', value: info?.title ?? '', editable: true },
{ key: 'composer', value: info?.composer ?? '', editable: true },
@@ -23,7 +28,7 @@ function scoreInfoFields(info) {
];
}
function instrFields(instr) {
function _instrFields(instr) {
return [
{ key: 'name', value: instr.name, editable: false },
{ key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' },
@@ -31,11 +36,11 @@ function instrFields(instr) {
];
}
function variationFields(v) {
function _variationFields(v) {
return [{ key: 'depends_on', value: v.dependsOn ?? '—', editable: true }];
}
function shapeSection(label, shape, onChange) {
function _shapeSection(label, shape, onChange) {
if (!shape) return null;
return h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, label),
@@ -53,15 +58,13 @@ export const PaneFO = {
return fp.length ? fp[fp.length - 1] : null;
}
// Intercepts the first edit to a linked instrument:
// shows embed-or-discard modal before committing. `info.undo`
// (forwarded from ShapeEditor/EnvelopeEditor) reverts the mutation on discard.
// Guard: first edit to a linked instrument triggers embed-or-discard before committing.
function makeChangeHandler(instr) {
return (info) => {
if (instr.isLinked && !instr.isDirty) {
if (instr.isLinked && !instr._modified) {
pendingEdit.value = { instr, undo: info?.undo };
} else {
instr.isDirty = true;
instr._modified = true;
props.store.markDirty();
}
};
@@ -70,7 +73,7 @@ export const PaneFO = {
function embedInstrument(instr) {
instr.name = instr.name.split('/').pop();
instr.isLinked = false;
instr.isDirty = true;
instr._modified = true;
pendingEdit.value = null;
props.store.markDirty();
}
@@ -96,7 +99,7 @@ export const PaneFO = {
return h('div', { class: 'se-fo-pane' }, [
h('h4', H4, 'Score'),
h(ObjectExtended, {
fields: scoreInfoFields(model.info),
fields: _scoreInfoFields(model.info),
onChange: ({ key, value }) => {
if (!model.info) model.info = {};
model.info[key] = value;
@@ -109,7 +112,7 @@ export const PaneFO = {
if (node.type === 'instrument') {
children.push(
h('h4', H4, `Instrument: ${node.name}`),
h(ObjectExtended, { fields: instrFields(node), onChange: null }),
h(ObjectExtended, { fields: _instrFields(node), onChange: null }),
);
} else if (node.type === 'variation') {
const instr = props.store.scoreModel.instruments.find(
@@ -120,7 +123,7 @@ export const PaneFO = {
children.push(
h('h4', H4, 'Variation'),
h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => {
h(ObjectExtended, { fields: _variationFields(node), onChange: ({ key, value }) => {
if (key === 'depends_on') {
const old = node.dependsOn;
node.dependsOn = value;
@@ -148,60 +151,152 @@ export const PaneFO = {
? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange })
: null,
);
} 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', 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 = () => { node.isDirty = true; props.store.markDirty(); };
const markBarDirty = () => { props.store.markDirty(); };
children.push(
h('h4', H4, `Bar: ${node.id}`),
h(ObjectExtended, {
fields: [
{ key: 'id', value: node.id, editable: false },
{ 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);
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),
_shapeSection('Upper stress bound', node.upperStressBound, markBarDirty),
_shapeSection('Lower stress bound', node.lowerStressBound, markBarDirty),
_shapeSection('Tempo shape', node.tempoShape, markBarDirty),
);
} else if (node.type === 'voice') {
children.push(
h('h4', H4, `Voice: ${node.name}`),
h(ObjectExtended, {
fields: [
{ key: 'articles', value: node.articles.join(', ') || '—', editable: false },
{ key: 'motifs', value: node.motifs.join(', ') || '—', editable: false },
{ key: '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 noteStr = n => `${n.pitch}${n.effLength != null ? ' ' + n.effLength : ''}`;
const clusterStr = c => c.notes.length
? c.notes.map(n => `${n.letter ?? ''}${n.shift != null ? n.shift : ''}${n.length != null ? ' ' + n.length : ''}`).join(', ')
: `cluster[${c.index}]`;
const noteItem = (text, i) => h('li', { class: 'se-object-item', key: i },
h('span', { class: 'se-object-label' }, text));
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 }],
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,
}),
node.stemNotes.length ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Stem notes'),
h('ul', { class: 'se-object-list' }, node.stemNotes.map((n, i) => noteItem(noteStr(n), i))),
]) : null,
node.clusters.length ? h('div', { style: 'margin-top:0.5rem' }, [
h('strong', null, 'Clusters'),
h('ul', { class: 'se-object-list' }, node.clusters.map((c, i) => noteItem(clusterStr(c), i))),
]) : null,
);
} else 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' },

View File

@@ -1,71 +1,147 @@
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;
}
export const PaneSubObjects = {
props: ['store', 'onFocusFO'],
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') {
const items = [];
if (node.info)
items.push({ kind: 'info', node: node.info, label: node.info.title ?? '(no title)', hasChildren: false });
if (node.tuning)
items.push({ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false });
for (const a of (node.articles ?? []))
items.push({ kind: 'article', node: a, label: a.name, hasChildren: false });
for (const sv of (node.stageVoices ?? []))
items.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false });
for (const i of node.instruments)
items.push({ kind: 'instrument', node: i, label: i.name, hasChildren: true });
for (const b of node.bars)
items.push({ kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0 });
return items;
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})` : ''}`,
hasChildren: true,
}));
}
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)', hasChildren: false,
})),
...node.subvariations.map((sv, idx) => {
const dep = sv.dependsOn;
const label = dep == null ? `subvariation ${idx + 1}`
: isNaN(Number(dep)) ? `ATTR: ${dep}`
: String(dep);
return { kind: 'variation', node: sv, label, hasChildren: true };
}),
];
}
if (node.type === 'bar') {
return Object.entries(node.voices).map(([name, v]) => ({
kind: 'voice', node: v, label: name, hasChildren: v.offsets.length > 0,
}));
}
if (node.type === 'voice') {
return node.offsets.map((o, idx) => ({
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: false,
}));
}
return [];
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', null, 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 ?? [];
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) =>
@@ -75,8 +151,11 @@ export const PaneSubObjects = {
typeTag: item.kind,
focused: props.store.focusPath.includes(item.node),
hasChildren: item.hasChildren,
onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); },
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,
})
))
);

View File

@@ -8,7 +8,7 @@ export const StatusPoller = {
async function poll() {
try {
const status = await fetchStatus(props.store.credentials);
const status = await fetchStatus();
props.store.synthesisStatus = status;
if (status.frozen) return;
} catch (_) {

View File

@@ -1,12 +1,10 @@
// RFC-compliant YAML serializer for Sompyler instrument blocks.
// Operates on the model produced by ast-parser.js buildModel().
import { stressorToString } from './util.js';
// ── Shape ──────────────────────────────────────────────────────────────────
// RFC §1.3.4.5: SHAPE = [PREFIX (":" / ";")] Node 1*(";" Node)
// Node = x "," y ["*" z] ["!"]
// PREFIX+colon is the duration/resolution; optional START+semicolon follows.
function serializeShape(shape) {
function _serializeShape(shape) {
if (!shape) return null;
const nodes = shape.coords.map(c => {
let s = `${c.x},${c.y}`;
@@ -20,75 +18,66 @@ function serializeShape(shape) {
return prefix + nodes;
}
// ── FM / AM modulation ─────────────────────────────────────────────────────
// RFC §3.2.1.1.6-7: FM = FREQUENCY ["f"/"F"] ["@" OSC] ["[" SHAPE "]"] ";" MOD ":" BASE
function serializeModulation(m) {
let s = String(m.frequency ?? '');
if (m.oscillator) s += `@${m.oscillator}`;
if (m.shape) s += `[${serializeShape(m.shape)}]`;
s += `;${m.mod_share ?? ''}:${m.base_share ?? ''}`;
if (m.init_phase != null) s += m.init_phase >= 0 ? `+${m.init_phase}` : String(m.init_phase);
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;
}
// ── Basic properties ───────────────────────────────────────────────────────
// RFC §3.2.1.1: O, A, S, R, FM go directly in the variation MAPPING.
// Returns array of YAML lines at 0 indent.
function basicPropLines(bp) {
function _basicPropLines(bp) {
if (!bp) return [];
const lines = [];
if (bp.oscillator) lines.push(`O: ${bp.oscillator}`);
const a = serializeShape(bp.A);
if (a) lines.push(`A: "${a}"`);
const s = serializeShape(bp.S);
if (s) lines.push(`S: "${s}"`);
const r = serializeShape(bp.R);
if (r) lines.push(`R: "${r}"`);
for (const fm of (bp.fmModulations ?? [])) lines.push(`FM: "${serializeModulation(fm)}"`);
for (const am of (bp.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
return lines;
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);
}
// ── Labelled property groups ───────────────────────────────────────────────
// RFC §3.2.1.2: label name (3+ lowercase chars) is the MAPPING KEY directly.
// Returns array of YAML lines at 0 indent.
function labelSpecLines(ls) {
const inner = basicPropLines(ls.basicProperties);
function _labelSpecLines(ls) {
const inner = _basicPropLines(ls.basicProperties);
if (!inner.length) return [`${ls.label}:`];
return [`${ls.label}:`, ...inner.map(l => ` ${l}`)];
}
// ── Variation ─────────────────────────────────────────────────────────────
// Returns YAML lines for one variation MAPPING (no leading "- ").
// RFC §3.2.1.3: VOLUMES, TIMBRE are variation properties, not instrument-level.
function variationLines(v) {
const lines = [];
if (v.dependsOn) lines.push(`ATTR: ${v.dependsOn}`);
lines.push(...basicPropLines(v.basicProperties));
for (const ls of (v.labelSpecs ?? [])) lines.push(...labelSpecLines(ls));
if (v.spread?.length) lines.push(`SPREAD: [${v.spread.join(', ')}]`);
if (v.railsbackCurve) { const rc = serializeShape(v.railsbackCurve); if (rc) lines.push(`RAILSBACK_CURVE: "${rc}"`); }
const vol = serializeShape(v.volumes);
if (vol) lines.push(`VOLUMES: "${vol}"`);
const timbre = serializeShape(v.timbre);
if (timbre) lines.push(`TIMBRE: "${timbre}"`);
for (const fm of (v.fmModulations ?? [])) lines.push(`FM: "${serializeModulation(fm)}"`);
for (const am of (v.amModulations ?? [])) lines.push(`AM: "${serializeModulation(am)}"`);
for (const sv of (v.subvariations ?? [])) lines.push(...variationLines(sv));
return lines;
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);
}
// ── Instrument character block ─────────────────────────────────────────────
// VOLUMES, TIMBRE, FM are variation properties (RFC §3.2.1.3). The AST parser
// stores them on the instrument because they appear at depth 01 (root variation
// is implicit when no character: wrapper exists). Promote them into a synthetic
// root variation here so the export structure is RFC-correct.
function instrCharacterLines(instr) {
function _instrCharacterLines(instr) {
const variations = instr.variations ?? [];
const hasRootProps = instr.basicProperties || instr.volumes || instr.timbre ||
(instr.fmModulations ?? []).length > 0 ||
@@ -111,50 +100,95 @@ function instrCharacterLines(instr) {
];
if (allVariations.length <= 1) {
const vLines = allVariations.length ? variationLines(allVariations[0]) : [];
const vLines = allVariations.length ? _variationLines(allVariations[0]) : [];
return vLines.map(l => ` ${l}`);
}
// Multiple variations — RFC MAYBE_LIST<VARIATION> as YAML sequence.
const result = [];
for (const v of allVariations) {
const vLines = variationLines(v);
if (!vLines.length) continue;
result.push(` - ${vLines[0]}`);
for (const l of vLines.slice(1)) result.push(` ${l}`);
}
return result;
return allVariations.flatMap(v => {
const vLines = _variationLines(v);
if (!vLines.length) return [];
return [` - ${vLines[0]}`, ...vLines.slice(1).map(l => ` ${l}`)];
});
}
// RFC §4.4.1: DATE = YYYY-MM-DD HH:MM:SS
// notChangedSince from AST log is a float epoch; score YAML must have ISO date.
function _epochToISO(val) {
if (!val) return null;
if (typeof val === 'string') return val;
return new Date(val * 1000).toISOString().slice(0, 19).replace('T', ' ');
}
function _nowISO() {
return new Date().toISOString().slice(0, 19).replace('T', ' ');
}
// ── Instrument ────────────────────────────────────────────────────────────
// RFC §4.4: embedded instrument key is "instrument NAME:" not "instrument: 'NAME'"
export function exportInstrument(instr) {
const lines = [`instrument ${instr.name}:`];
if (instr.notChangedSince) lines.push(` NOT_CHANGED_SINCE: ${instr.notChangedSince}`);
lines.push(` NOT_CHANGED_SINCE: ${_nowISO()}`);
lines.push(` character:`);
lines.push(...instrCharacterLines(instr));
lines.push(..._instrCharacterLines(instr));
return lines.join('\n');
}
// RFC §4.3: articles: MAPPING { LABEL: { ATTR: VALUE ... } ... }
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;
}
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');
}
// ── Score patch ────────────────────────────────────────────────────────────
// Replace dirty instrument blocks and dirty bar _meta blocks.
// Voice note content in bar documents is left verbatim.
const META_KEYS = ['title', 'composer', 'source', 'encrypter'];
function patchMetadata(text, info) {
function _patchMetadata(text, info) {
if (!info) return text;
const lines = text.split('\n');
const replaced = new Set();
const out = lines.map(line => {
for (const key of META_KEYS) {
if (line.startsWith(key + ':') && info[key] != null && info[key] !== '') {
replaced.add(key);
return `${key}: ${info[key]}`;
}
}
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;
});
@@ -166,14 +200,14 @@ function patchMetadata(text, info) {
return out.join('\n');
}
function patchInstrumentHeader(text, instruments) {
function _patchInstrumentHeader(text, instruments) {
const lines = text.split('\n');
const result = [];
const instrMap = {};
for (const instr of instruments) {
instrMap[instr.name] = instr;
if (instr.name.includes('/')) instrMap[instr.name.split('/').pop()] = instr;
}
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) {
@@ -182,14 +216,25 @@ function patchInstrumentHeader(text, instruments) {
if (m) {
const rawName = m[1].replace(/^'|'$/g, '');
const instr = instrMap[rawName];
if (instr && instr.isDirty) {
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);
@@ -200,22 +245,78 @@ function patchInstrumentHeader(text, instruments) {
return result.join('\n');
}
export function stressorToString(s) {
if (!s?.groups?.length) return '';
return s.groups.map(g => g.join(',')).join(';');
// 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) {
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);
const ub = _serializeShape(bar.upperStressBound);
if (ub) props.push(` upper_stress_bound: ${ub}`);
const lb = serializeShape(bar.lowerStressBound);
const lb = _serializeShape(bar.lowerStressBound);
if (lb) props.push(` lower_stress_bound: ${lb}`);
if (bar.tempoShape) { const ts = serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
if (bar.tempoShape) { const ts = _serializeShape(bar.tempoShape); if (ts) props.push(` tempo_shape: "${ts}"`); }
const lines = doc.split('\n');
const out = [];
@@ -242,24 +343,54 @@ function patchBarMeta(doc, bar) {
return out.join('\n');
}
export function patchScore(rawScoreText, instruments, bars = [], info = null) {
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);
const patchedHeader = patchInstrumentHeader(patchMetadata(header, info), instruments);
let patchedHeader = _patchMetadata(header, info);
if (!barDocs.length) return patchedHeader;
if (articles.length) {
patchedHeader = _patchArticles(patchedHeader, articles);
articles.forEach(a => log.push({ level: 'changed', path: `articles / ${a.name}` }));
}
const barMap = {};
for (const bar of bars) barMap[bar.id] = bar;
const patchedBarDocs = barDocs.map(doc => {
const m = doc.match(/^_id:\s*(\S+)/m);
if (!m) return doc;
const bar = barMap[m[1]];
if (!bar?.isDirty) return doc;
return patchBarMeta(doc, bar);
patchedHeader = _patchInstrumentHeader(patchedHeader, instruments);
patchedHeader = _patchStageSection(patchedHeader, instruments);
instruments.forEach(i => {
if (!(i.isLinked && !i._modified)) log.push({ level: 'changed', path: `instrument / ${i.name}` });
});
return [patchedHeader, ...patchedBarDocs].join(SEP);
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
View 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: [] };
}
}

View File

@@ -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 = [];
},
});

View File

@@ -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
View 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
View 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(';');
}

View File

@@ -4,7 +4,9 @@
import { readFileSync } from 'fs';
import { parseAstLog, buildModel } from './static/ast-parser.js';
import { exportInstrument, patchScore, stressorToString } from './static/exporter.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');
@@ -31,6 +33,69 @@ 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');
@@ -192,23 +257,22 @@ const SCORE_FIXTURE = new URL('./fixtures/pathetique.spls', import.meta.url);
const rawScore = readFileSync(SCORE_FIXTURE, 'utf8');
// pathetique.spls contains alpha and ki; dev/piano is a linked instrument not embedded.
// Mark only alpha as dirty, verify ki is preserved verbatim.
const patchInstruments = model.instruments.map(i => ({ ...i, isDirty: i.name === 'alpha' }));
const patched = patchScore(rawScore, patchInstruments);
// 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')));
// Ki is clean — its block must appear verbatim (check a unique line from the original)
const kiOrigLines = rawScore.split('\n').filter(l => l.startsWith('instrument ki:') || (l.startsWith(' ') && rawScore.indexOf('instrument ki:') < rawScore.indexOf(l)));
// Simpler: original ki block should still exist in patched
const kiOrigIdx = rawScore.indexOf('\ninstrument ki:');
const kiBlock = kiOrigIdx >= 0 ? rawScore.slice(kiOrigIdx + 1, rawScore.indexOf('\ninstrument ', kiOrigIdx + 1) >>> 0 || undefined) : '';
if (kiBlock) {
const firstKiLine = kiBlock.split('\n')[0];
ok('ki block preserved verbatim (first line)', patched.includes(firstKiLine));
// 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
@@ -216,7 +280,7 @@ ok('patched score is non-empty', patched.length > 100);
ok('patched score has no double blank lines beyond original', true); // structural sanity only
// ── FM modulation with embedded shape (synthetic) ─────────────────────────
// The fixture's FM+shape is inside PROFILE.partial (rawChildren) and unreachable
// 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'
@@ -381,7 +445,6 @@ voice soprano:
const dirtyBar = {
id: '001P1L1M1',
isDirty: true,
stressor: { groups: [[2, 3], [1]] },
tempoLevels: 140,
upperStressBound: null,
@@ -390,18 +453,23 @@ const dirtyBar = {
};
const cleanBar = {
id: '001P1L1M2',
isDirty: false,
stressor: null, tempoLevels: null,
stressor: null, tempoLevels: 100,
upperStressBound: null, lowerStressBound: null, tempoShape: null,
};
const barPatched = patchScore(RAW_SCORE_WITH_BARS, [], [dirtyBar, cleanBar]);
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 unchanged', barPatched.includes('beats_per_minute: 100'));
ok('clean bar BPM preserved via model', barPatched.includes('beats_per_minute: 100'));
ok('clean bar voice preserved', barPatched.includes('- D4 4'));
ok('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');
@@ -414,21 +482,324 @@ instrument alpha:
A: "1:0,10;1,0"
`;
const updatedInfo = { title: 'New Title', composer: 'New Composer', source: '', encrypter: 'Me' };
const metaPatched = patchScore(META_SCORE, [], [], updatedInfo);
const { text: metaPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], updatedInfo);
ok('existing title replaced', metaPatched.includes('title: New Title'));
ok('existing composer replaced', metaPatched.includes('composer: New Composer'));
ok('empty value leaves existing line', metaPatched.includes('source: Some Book'));
ok('new key prepended', metaPatched.includes('encrypter: Me'));
ok('instrument block untouched', metaPatched.includes('instrument alpha:'));
ok('instrument block present', metaPatched.includes('instrument alpha:'));
const META_SCORE_NO_TITLE = `composer: Bach\n\ninstrument ki:\n character:\n`;
const noTitlePatched = patchScore(META_SCORE_NO_TITLE, [], [], { title: 'Fugue', composer: 'Bach' });
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 nullInfoPatched = patchScore(META_SCORE, [], [], null);
const { text: nullInfoPatched } = patchScore(META_SCORE, [STUB_ALPHA], [], null);
ok('null info leaves metadata unchanged', nullInfoPatched.includes('title: Old Title'));
// ── 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);