@k-l-lambda/lilylet 0.1.75 → 0.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/lilylet/grammar.jison.js +23 -16
- package/lib/lilylet/lilypondEncoder.js +10 -7
- package/lib/lilylet/measureLayout.d.ts +11 -0
- package/lib/lilylet/measureLayout.js +97 -0
- package/lib/lilylet/meiEncoder.js +84 -63
- package/lib/lilylet/serializer.js +7 -2
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +23 -16
- package/source/lilylet/lilylet.jison +23 -16
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +92 -0
- package/source/lilylet/meiEncoder.ts +81 -59
- package/source/lilylet/serializer.ts +7 -2
|
@@ -108,34 +108,34 @@ case 13:
|
|
|
108
108
|
this.$ = mergeHeaders($$[$0-2], $$[$0]);
|
|
109
109
|
break;
|
|
110
110
|
case 14:
|
|
111
|
-
this.$ = ({ title: $$[$0-1]
|
|
111
|
+
this.$ = ({ title: unq($$[$0-1]) });
|
|
112
112
|
break;
|
|
113
113
|
case 15:
|
|
114
|
-
this.$ = ({ subtitle: $$[$0-1]
|
|
114
|
+
this.$ = ({ subtitle: unq($$[$0-1]) });
|
|
115
115
|
break;
|
|
116
116
|
case 16:
|
|
117
|
-
this.$ = ({ composer: $$[$0-1]
|
|
117
|
+
this.$ = ({ composer: unq($$[$0-1]) });
|
|
118
118
|
break;
|
|
119
119
|
case 17:
|
|
120
|
-
this.$ = ({ arranger: $$[$0-1]
|
|
120
|
+
this.$ = ({ arranger: unq($$[$0-1]) });
|
|
121
121
|
break;
|
|
122
122
|
case 18:
|
|
123
|
-
this.$ = ({ lyricist: $$[$0-1]
|
|
123
|
+
this.$ = ({ lyricist: unq($$[$0-1]) });
|
|
124
124
|
break;
|
|
125
125
|
case 19:
|
|
126
|
-
this.$ = ({ opus: $$[$0-1]
|
|
126
|
+
this.$ = ({ opus: unq($$[$0-1]) });
|
|
127
127
|
break;
|
|
128
128
|
case 20:
|
|
129
|
-
this.$ = ({ instrument: $$[$0-1]
|
|
129
|
+
this.$ = ({ instrument: unq($$[$0-1]) });
|
|
130
130
|
break;
|
|
131
131
|
case 21:
|
|
132
|
-
this.$ = instrumentStaff($$[$0-2], $$[$0-1]
|
|
132
|
+
this.$ = instrumentStaff($$[$0-2], unq($$[$0-1]));
|
|
133
133
|
break;
|
|
134
134
|
case 22:
|
|
135
|
-
this.$ = instrumentStaff($$[$0-3], $$[$0-2]
|
|
135
|
+
this.$ = instrumentStaff($$[$0-3], unq($$[$0-2]), unq($$[$0-1]));
|
|
136
136
|
break;
|
|
137
137
|
case 23:
|
|
138
|
-
this.$ = ({ genre: $$[$0-1]
|
|
138
|
+
this.$ = ({ genre: unq($$[$0-1]) });
|
|
139
139
|
break;
|
|
140
140
|
case 24:
|
|
141
141
|
this.$ = ({ staves: $$[$0-1].slice(1, -1) });
|
|
@@ -214,13 +214,13 @@ case 50:
|
|
|
214
214
|
this.$ = harmonyEvent($$[$0].slice(1, -1));
|
|
215
215
|
break;
|
|
216
216
|
case 51:
|
|
217
|
-
this.$ = markupEvent($$[$0]
|
|
217
|
+
this.$ = markupEvent(unq($$[$0]));
|
|
218
218
|
break;
|
|
219
219
|
case 52:
|
|
220
|
-
this.$ = markupEvent($$[$0]
|
|
220
|
+
this.$ = markupEvent(unq($$[$0]), 'above');
|
|
221
221
|
break;
|
|
222
222
|
case 53:
|
|
223
|
-
this.$ = markupEvent($$[$0]
|
|
223
|
+
this.$ = markupEvent(unq($$[$0]), 'below');
|
|
224
224
|
break;
|
|
225
225
|
case 54:
|
|
226
226
|
this.$ = dynamicEvent($$[$0].type);
|
|
@@ -351,10 +351,10 @@ case 95:
|
|
|
351
351
|
numericTimeSignature = false; this.$ = null;
|
|
352
352
|
break;
|
|
353
353
|
case 97:
|
|
354
|
-
this.$ = ({ text: $$[$0-3]
|
|
354
|
+
this.$ = ({ text: unq($$[$0-3]), beat: $$[$0-2], bpm: Number($$[$0]) });
|
|
355
355
|
break;
|
|
356
356
|
case 98:
|
|
357
|
-
this.$ = ({ text: $$[$0]
|
|
357
|
+
this.$ = ({ text: unq($$[$0]) });
|
|
358
358
|
break;
|
|
359
359
|
case 99:
|
|
360
360
|
this.$ = ({ beat: $$[$0-2], bpm: Number($$[$0]) });
|
|
@@ -525,7 +525,7 @@ case 187:
|
|
|
525
525
|
this.$ = navigation('segno');
|
|
526
526
|
break;
|
|
527
527
|
case 188:
|
|
528
|
-
this.$ = markupMark($$[$0]
|
|
528
|
+
this.$ = markupMark(unq($$[$0]));
|
|
529
529
|
break;
|
|
530
530
|
}
|
|
531
531
|
},
|
|
@@ -808,6 +808,13 @@ parse: function parse(input) {
|
|
|
808
808
|
const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
|
|
809
809
|
const markupMark = (content) => ({ markType: 'markup', content });
|
|
810
810
|
|
|
811
|
+
// Strip the surrounding quotes from a STRING token and unescape the control
|
|
812
|
+
// sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
|
|
813
|
+
// left-to-right pass over each \x pair avoids the sequential-replace bug where
|
|
814
|
+
// an escaped backslash (\\) followed by n would be misread as a newline.
|
|
815
|
+
const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
|
|
816
|
+
const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
|
|
817
|
+
|
|
811
818
|
// Build an { instruments: { <key>: { name, shortName? } } } fragment from a
|
|
812
819
|
// `[instrument-<key>` header token. <key> is a staff-layout group key — a single
|
|
813
820
|
// staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
|
|
@@ -622,26 +622,29 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
|
|
|
622
622
|
*/
|
|
623
623
|
const encodeMetadata = (metadata) => {
|
|
624
624
|
const entries = [];
|
|
625
|
+
// LilyPond strings escape backslash and double-quote with a backslash. An
|
|
626
|
+
// unescaped " (or \) inside a header value otherwise breaks the \header block.
|
|
627
|
+
const ly = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
625
628
|
if (metadata.title) {
|
|
626
|
-
entries.push(` title = "${metadata.title}"`);
|
|
629
|
+
entries.push(` title = "${ly(metadata.title)}"`);
|
|
627
630
|
}
|
|
628
631
|
if (metadata.subtitle) {
|
|
629
|
-
entries.push(` subtitle = "${metadata.subtitle}"`);
|
|
632
|
+
entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
|
|
630
633
|
}
|
|
631
634
|
if (metadata.composer) {
|
|
632
|
-
entries.push(` composer = "${metadata.composer}"`);
|
|
635
|
+
entries.push(` composer = "${ly(metadata.composer)}"`);
|
|
633
636
|
}
|
|
634
637
|
if (metadata.arranger) {
|
|
635
|
-
entries.push(` arranger = "${metadata.arranger}"`);
|
|
638
|
+
entries.push(` arranger = "${ly(metadata.arranger)}"`);
|
|
636
639
|
}
|
|
637
640
|
if (metadata.lyricist) {
|
|
638
|
-
entries.push(` poet = "${metadata.lyricist}"`);
|
|
641
|
+
entries.push(` poet = "${ly(metadata.lyricist)}"`);
|
|
639
642
|
}
|
|
640
643
|
if (metadata.opus) {
|
|
641
|
-
entries.push(` opus = "${metadata.opus}"`);
|
|
644
|
+
entries.push(` opus = "${ly(metadata.opus)}"`);
|
|
642
645
|
}
|
|
643
646
|
if (metadata.instrument) {
|
|
644
|
-
entries.push(` instrument = "${metadata.instrument}"`);
|
|
647
|
+
entries.push(` instrument = "${ly(metadata.instrument)}"`);
|
|
645
648
|
}
|
|
646
649
|
entries.push(' tagline = ##f');
|
|
647
650
|
return entries.join('\n');
|
|
@@ -64,5 +64,16 @@ export interface VoltaEndingSpan {
|
|
|
64
64
|
measures: number[];
|
|
65
65
|
}
|
|
66
66
|
export declare const collectVoltaSpans: (layout: MeasureLayout) => VoltaEndingSpan[];
|
|
67
|
+
export interface LayoutSegment {
|
|
68
|
+
id: string;
|
|
69
|
+
kind: "section" | "ending";
|
|
70
|
+
endingNumber?: number;
|
|
71
|
+
measures: number[];
|
|
72
|
+
}
|
|
73
|
+
export interface SegmentDecomposition {
|
|
74
|
+
segments: LayoutSegment[];
|
|
75
|
+
order: string[];
|
|
76
|
+
}
|
|
77
|
+
export declare const decomposeToSegments: (layout: MeasureLayout) => SegmentDecomposition | null;
|
|
67
78
|
/** Serialize an AST back to the canonical index-wise DSL string. */
|
|
68
79
|
export declare const serializeMeasureLayout: (layout: MeasureLayout) => string;
|
|
@@ -362,6 +362,103 @@ export const collectVoltaSpans = (layout) => {
|
|
|
362
362
|
walk(layout);
|
|
363
363
|
return spans;
|
|
364
364
|
};
|
|
365
|
+
export const decomposeToSegments = (layout) => {
|
|
366
|
+
const segments = [];
|
|
367
|
+
let hasEnding = false;
|
|
368
|
+
let counter = 0;
|
|
369
|
+
const newId = (k) => `seg-${k}-${counter++}`;
|
|
370
|
+
// Indices a node covers in notation order (each measure once, ascending).
|
|
371
|
+
const indicesOf = (node) => {
|
|
372
|
+
switch (node.kind) {
|
|
373
|
+
case "single": return [node.measure];
|
|
374
|
+
case "block": return [].concat(...node.seq.map(indicesOf));
|
|
375
|
+
case "volta": return [].concat(...node.body.map(indicesOf), ...(node.alternates ? [].concat(...node.alternates.map(a => [].concat(...a.map(indicesOf)))) : []));
|
|
376
|
+
case "aba": return [].concat(indicesOf(node.main), ...node.rest.map(indicesOf));
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const contiguousAsc = (xs) => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
|
|
380
|
+
// Accumulate a run of plain (non-volta/non-aba) measures into one section,
|
|
381
|
+
// flushing whenever a structural node (volta/aba) interrupts the run.
|
|
382
|
+
let run = [];
|
|
383
|
+
const flushRun = () => {
|
|
384
|
+
if (run.length === 0)
|
|
385
|
+
return;
|
|
386
|
+
if (!contiguousAsc(run))
|
|
387
|
+
throw new Error("non-contiguous section run");
|
|
388
|
+
segments.push({ id: newId("sec"), kind: "section", measures: run });
|
|
389
|
+
run = [];
|
|
390
|
+
};
|
|
391
|
+
// Emit segments for a node in document order. Plain singles/blocks extend the
|
|
392
|
+
// current run; a volta flushes the run then emits body-section + ending(s); an
|
|
393
|
+
// aba flushes then recurses into main and rest (so the replayed main is its own
|
|
394
|
+
// segment run). Throws on any non-contiguous group → caller falls back to flat.
|
|
395
|
+
const emit = (node) => {
|
|
396
|
+
switch (node.kind) {
|
|
397
|
+
case "single":
|
|
398
|
+
run.push(node.measure);
|
|
399
|
+
break;
|
|
400
|
+
case "block":
|
|
401
|
+
node.seq.forEach(emit);
|
|
402
|
+
break;
|
|
403
|
+
case "aba":
|
|
404
|
+
flushRun();
|
|
405
|
+
emit(node.main);
|
|
406
|
+
flushRun();
|
|
407
|
+
node.rest.forEach(emit);
|
|
408
|
+
flushRun();
|
|
409
|
+
break;
|
|
410
|
+
case "volta": {
|
|
411
|
+
flushRun();
|
|
412
|
+
const body = indicesOf({ kind: "block", seq: node.body });
|
|
413
|
+
if (!contiguousAsc(body))
|
|
414
|
+
throw new Error("non-contiguous volta body");
|
|
415
|
+
segments.push({ id: newId("body"), kind: "section", measures: body });
|
|
416
|
+
if (node.alternates) {
|
|
417
|
+
hasEnding = true;
|
|
418
|
+
node.alternates.forEach((alt, ai) => {
|
|
419
|
+
const m = indicesOf({ kind: "block", seq: alt });
|
|
420
|
+
if (!contiguousAsc(m))
|
|
421
|
+
throw new Error("non-contiguous ending");
|
|
422
|
+
segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
emit(layout);
|
|
431
|
+
flushRun();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
if (!hasEnding)
|
|
437
|
+
return null; // no voltas → flat measure-level path is fine
|
|
438
|
+
// Map each measure index to the segment whose `measures` contains it. A volta
|
|
439
|
+
// body measure belongs to its body section; alternate measures to their ending.
|
|
440
|
+
const segOfMeasure = new Map();
|
|
441
|
+
for (const s of segments)
|
|
442
|
+
for (const m of s.measures)
|
|
443
|
+
if (!segOfMeasure.has(m))
|
|
444
|
+
segOfMeasure.set(m, s.id);
|
|
445
|
+
// Performance order over segment ids: walk the flat Full expansion and collapse
|
|
446
|
+
// consecutive measures that map to the same segment into one segment ref. This
|
|
447
|
+
// reproduces the exact playback order (body repeats → body id appears twice,
|
|
448
|
+
// ABA replay → main segment id reappears), keyed off the proven expander.
|
|
449
|
+
const flat = expandMeasureLayout(layout, LayoutType.Full);
|
|
450
|
+
const order = [];
|
|
451
|
+
for (const m of flat) {
|
|
452
|
+
const id = segOfMeasure.get(m);
|
|
453
|
+
if (id === undefined)
|
|
454
|
+
return null; // a played measure isn't in any segment → bail to flat
|
|
455
|
+
if (order.length === 0 || order[order.length - 1] !== id)
|
|
456
|
+
order.push(id);
|
|
457
|
+
else if (segments.find(s => s.id === id).measures[0] === m)
|
|
458
|
+
order.push(id); // same seg restarting (repeat) → new ref
|
|
459
|
+
}
|
|
460
|
+
return { segments, order };
|
|
461
|
+
};
|
|
365
462
|
// ── Serializer ───────────────────────────────────────────────────────────
|
|
366
463
|
// Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
|
|
367
464
|
// "A..B" ranges (lotus's seqToCode).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
2
|
import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
|
|
3
3
|
import { gmProgramOf } from "./gmInstruments.js";
|
|
4
|
-
import { parseMeasureLayout, expandMeasureLayout,
|
|
4
|
+
import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout.js";
|
|
5
5
|
// MEI key signatures: positive = sharps, negative = flats
|
|
6
6
|
const KEY_SIGS = {
|
|
7
7
|
0: "0",
|
|
@@ -2294,31 +2294,70 @@ const encode = (doc, options = {}) => {
|
|
|
2294
2294
|
// below — so remember where to insert it and build it afterwards).
|
|
2295
2295
|
const expansionInsertPos = mei.length;
|
|
2296
2296
|
const measureIds = [];
|
|
2297
|
-
// Volta
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
|
|
2304
|
-
const
|
|
2297
|
+
// Volta playback + house brackets. A flat measure-level <expansion> conflicts
|
|
2298
|
+
// with <ending> wrappers in verovio (it then drops the volta measures). Verovio
|
|
2299
|
+
// instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
|
|
2300
|
+
// whose plist references those segment ids. So when the layout decomposes into
|
|
2301
|
+
// volta segments, emit that structure; otherwise keep the simpler flat path.
|
|
2302
|
+
// See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
|
|
2303
|
+
// Helper to check if a measure has any musical content
|
|
2304
|
+
const measureHasContent = (measure) => {
|
|
2305
|
+
for (const part of measure.parts) {
|
|
2306
|
+
for (const voice of part.voices) {
|
|
2307
|
+
for (const event of voice.events) {
|
|
2308
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
2309
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
2310
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2311
|
+
return true;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
return false;
|
|
2317
|
+
};
|
|
2318
|
+
// Filter out trailing empty measures (done before segment setup so the segment
|
|
2319
|
+
// guard below knows the true emitted measure count).
|
|
2320
|
+
let measures = doc.measures;
|
|
2321
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2322
|
+
measures = measures.slice(0, -1);
|
|
2323
|
+
}
|
|
2324
|
+
let segDecomp = null;
|
|
2325
|
+
const segOpenAt = new Map(); // measure index → opening tag for its segment
|
|
2326
|
+
const segCloseAt = new Map(); // measure index → closing tag after it
|
|
2327
|
+
const segIdById = new Map(); // layout segment id → emitted xml:id
|
|
2305
2328
|
if (doc.metadata?.measureLayout) {
|
|
2306
2329
|
try {
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2330
|
+
segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2331
|
+
}
|
|
2332
|
+
catch {
|
|
2333
|
+
segDecomp = null;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// A layout that references a measure beyond the emitted range (e.g. a volta
|
|
2337
|
+
// alternate whose bar was a trailing empty measure that got trimmed) cannot be
|
|
2338
|
+
// emitted as nested sections without a dangling <ending>/<section> id or an
|
|
2339
|
+
// unbalanced wrapper. Drop to the flat measure-level expansion path, which
|
|
2340
|
+
// already warns-and-skips out-of-range refs.
|
|
2341
|
+
if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
|
|
2342
|
+
segDecomp = null;
|
|
2343
|
+
}
|
|
2344
|
+
if (segDecomp) {
|
|
2345
|
+
const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2346
|
+
for (const seg of segDecomp.segments) {
|
|
2347
|
+
const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
|
|
2348
|
+
segIdById.set(seg.id, xmlId);
|
|
2349
|
+
const lo = seg.measures[0];
|
|
2350
|
+
const hi = seg.measures[seg.measures.length - 1];
|
|
2351
|
+
if (seg.kind === "ending") {
|
|
2352
|
+
const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
|
|
2353
|
+
segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
|
|
2354
|
+
}
|
|
2355
|
+
else {
|
|
2356
|
+
segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
|
|
2317
2357
|
}
|
|
2358
|
+
segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
|
|
2318
2359
|
}
|
|
2319
|
-
catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
|
|
2320
2360
|
}
|
|
2321
|
-
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2322
2361
|
// Track tie state across measures for cross-measure ties
|
|
2323
2362
|
const tieState = {};
|
|
2324
2363
|
// Track slur state across measures for cross-measure slurs
|
|
@@ -2344,26 +2383,6 @@ const encode = (doc, options = {}) => {
|
|
|
2344
2383
|
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2345
2384
|
}
|
|
2346
2385
|
}
|
|
2347
|
-
// Helper to check if a measure has any musical content
|
|
2348
|
-
const measureHasContent = (measure) => {
|
|
2349
|
-
for (const part of measure.parts) {
|
|
2350
|
-
for (const voice of part.voices) {
|
|
2351
|
-
for (const event of voice.events) {
|
|
2352
|
-
// Check for actual musical content (not just context changes or pitch resets)
|
|
2353
|
-
if (event.type === 'note' || event.type === 'rest' ||
|
|
2354
|
-
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2355
|
-
return true;
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
return false;
|
|
2361
|
-
};
|
|
2362
|
-
// Filter out trailing empty measures
|
|
2363
|
-
let measures = doc.measures;
|
|
2364
|
-
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2365
|
-
measures = measures.slice(0, -1);
|
|
2366
|
-
}
|
|
2367
2386
|
// Encode measures
|
|
2368
2387
|
measures.forEach((measure, mi) => {
|
|
2369
2388
|
// Check for key signature change and output scoreDef if needed
|
|
@@ -2427,31 +2446,33 @@ const encode = (doc, options = {}) => {
|
|
|
2427
2446
|
}
|
|
2428
2447
|
const mIndex = mi + 1;
|
|
2429
2448
|
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2430
|
-
//
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
mei +=
|
|
2435
|
-
|
|
2436
|
-
}
|
|
2437
|
-
const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
|
|
2449
|
+
// When the layout decomposes into volta segments, wrap each measure run in
|
|
2450
|
+
// its <section>/<ending> container (opened/closed by measure index).
|
|
2451
|
+
const openTag = segOpenAt.get(mIndex);
|
|
2452
|
+
if (openTag)
|
|
2453
|
+
mei += openTag;
|
|
2454
|
+
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2438
2455
|
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2439
|
-
|
|
2440
|
-
if (
|
|
2441
|
-
mei +=
|
|
2442
|
-
endingOpen = false;
|
|
2443
|
-
}
|
|
2456
|
+
const closeTag = segCloseAt.get(mIndex);
|
|
2457
|
+
if (closeTag)
|
|
2458
|
+
mei += closeTag;
|
|
2444
2459
|
});
|
|
2445
|
-
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2446
|
-
if (endingOpen) {
|
|
2447
|
-
mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`;
|
|
2448
|
-
endingOpen = false;
|
|
2449
|
-
}
|
|
2450
2460
|
// Emit an <expansion> describing playback order from the measure-layout
|
|
2451
|
-
// directive ([measures "..."]). Verovio
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
|
|
2461
|
+
// directive ([measures "..."]). Verovio consumes its plist to unfold repeats
|
|
2462
|
+
// for MIDI/time-map output. Inserted as the first child of <section>.
|
|
2463
|
+
// - voltas present (segDecomp): plist references the SECTION/ENDING segment
|
|
2464
|
+
// ids in performed order (a body segment id repeats per pass). This is the
|
|
2465
|
+
// only form verovio plays correctly alongside <ending> house brackets.
|
|
2466
|
+
// - otherwise: the simpler flat measure-level plist (one ref per played bar).
|
|
2467
|
+
if (segDecomp) {
|
|
2468
|
+
const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x) => x !== undefined).map(id => `#${id}`);
|
|
2469
|
+
if (refs.length > 0) {
|
|
2470
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2471
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2472
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
else if (doc.metadata?.measureLayout) {
|
|
2455
2476
|
try {
|
|
2456
2477
|
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2457
2478
|
const order = expandMeasureLayout(layout);
|
|
@@ -507,7 +507,7 @@ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) =
|
|
|
507
507
|
case 'markup': {
|
|
508
508
|
const mk = event;
|
|
509
509
|
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
510
|
-
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
510
|
+
return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
|
|
511
511
|
}
|
|
512
512
|
case 'dynamic': {
|
|
513
513
|
const dynStr = DYNAMIC_MAP[event.dynamicType];
|
|
@@ -841,7 +841,12 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
841
841
|
};
|
|
842
842
|
// Escape string for serialization (quotes and backslashes)
|
|
843
843
|
const escapeString = (str) => {
|
|
844
|
-
return str
|
|
844
|
+
return str
|
|
845
|
+
.replace(/\\/g, '\\\\')
|
|
846
|
+
.replace(/"/g, '\\"')
|
|
847
|
+
.replace(/\n/g, '\\n')
|
|
848
|
+
.replace(/\r/g, '\\r')
|
|
849
|
+
.replace(/\t/g, '\\t');
|
|
845
850
|
};
|
|
846
851
|
// Serialize metadata
|
|
847
852
|
const serializeMetadata = (metadata) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.76",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -108,34 +108,34 @@ case 13:
|
|
|
108
108
|
this.$ = mergeHeaders($$[$0-2], $$[$0]);
|
|
109
109
|
break;
|
|
110
110
|
case 14:
|
|
111
|
-
this.$ = ({ title: $$[$0-1]
|
|
111
|
+
this.$ = ({ title: unq($$[$0-1]) });
|
|
112
112
|
break;
|
|
113
113
|
case 15:
|
|
114
|
-
this.$ = ({ subtitle: $$[$0-1]
|
|
114
|
+
this.$ = ({ subtitle: unq($$[$0-1]) });
|
|
115
115
|
break;
|
|
116
116
|
case 16:
|
|
117
|
-
this.$ = ({ composer: $$[$0-1]
|
|
117
|
+
this.$ = ({ composer: unq($$[$0-1]) });
|
|
118
118
|
break;
|
|
119
119
|
case 17:
|
|
120
|
-
this.$ = ({ arranger: $$[$0-1]
|
|
120
|
+
this.$ = ({ arranger: unq($$[$0-1]) });
|
|
121
121
|
break;
|
|
122
122
|
case 18:
|
|
123
|
-
this.$ = ({ lyricist: $$[$0-1]
|
|
123
|
+
this.$ = ({ lyricist: unq($$[$0-1]) });
|
|
124
124
|
break;
|
|
125
125
|
case 19:
|
|
126
|
-
this.$ = ({ opus: $$[$0-1]
|
|
126
|
+
this.$ = ({ opus: unq($$[$0-1]) });
|
|
127
127
|
break;
|
|
128
128
|
case 20:
|
|
129
|
-
this.$ = ({ instrument: $$[$0-1]
|
|
129
|
+
this.$ = ({ instrument: unq($$[$0-1]) });
|
|
130
130
|
break;
|
|
131
131
|
case 21:
|
|
132
|
-
this.$ = instrumentStaff($$[$0-2], $$[$0-1]
|
|
132
|
+
this.$ = instrumentStaff($$[$0-2], unq($$[$0-1]));
|
|
133
133
|
break;
|
|
134
134
|
case 22:
|
|
135
|
-
this.$ = instrumentStaff($$[$0-3], $$[$0-2]
|
|
135
|
+
this.$ = instrumentStaff($$[$0-3], unq($$[$0-2]), unq($$[$0-1]));
|
|
136
136
|
break;
|
|
137
137
|
case 23:
|
|
138
|
-
this.$ = ({ genre: $$[$0-1]
|
|
138
|
+
this.$ = ({ genre: unq($$[$0-1]) });
|
|
139
139
|
break;
|
|
140
140
|
case 24:
|
|
141
141
|
this.$ = ({ staves: $$[$0-1].slice(1, -1) });
|
|
@@ -214,13 +214,13 @@ case 50:
|
|
|
214
214
|
this.$ = harmonyEvent($$[$0].slice(1, -1));
|
|
215
215
|
break;
|
|
216
216
|
case 51:
|
|
217
|
-
this.$ = markupEvent($$[$0]
|
|
217
|
+
this.$ = markupEvent(unq($$[$0]));
|
|
218
218
|
break;
|
|
219
219
|
case 52:
|
|
220
|
-
this.$ = markupEvent($$[$0]
|
|
220
|
+
this.$ = markupEvent(unq($$[$0]), 'above');
|
|
221
221
|
break;
|
|
222
222
|
case 53:
|
|
223
|
-
this.$ = markupEvent($$[$0]
|
|
223
|
+
this.$ = markupEvent(unq($$[$0]), 'below');
|
|
224
224
|
break;
|
|
225
225
|
case 54:
|
|
226
226
|
this.$ = dynamicEvent($$[$0].type);
|
|
@@ -351,10 +351,10 @@ case 95:
|
|
|
351
351
|
numericTimeSignature = false; this.$ = null;
|
|
352
352
|
break;
|
|
353
353
|
case 97:
|
|
354
|
-
this.$ = ({ text: $$[$0-3]
|
|
354
|
+
this.$ = ({ text: unq($$[$0-3]), beat: $$[$0-2], bpm: Number($$[$0]) });
|
|
355
355
|
break;
|
|
356
356
|
case 98:
|
|
357
|
-
this.$ = ({ text: $$[$0]
|
|
357
|
+
this.$ = ({ text: unq($$[$0]) });
|
|
358
358
|
break;
|
|
359
359
|
case 99:
|
|
360
360
|
this.$ = ({ beat: $$[$0-2], bpm: Number($$[$0]) });
|
|
@@ -525,7 +525,7 @@ case 187:
|
|
|
525
525
|
this.$ = navigation('segno');
|
|
526
526
|
break;
|
|
527
527
|
case 188:
|
|
528
|
-
this.$ = markupMark($$[$0]
|
|
528
|
+
this.$ = markupMark(unq($$[$0]));
|
|
529
529
|
break;
|
|
530
530
|
}
|
|
531
531
|
},
|
|
@@ -808,6 +808,13 @@ parse: function parse(input) {
|
|
|
808
808
|
const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
|
|
809
809
|
const markupMark = (content) => ({ markType: 'markup', content });
|
|
810
810
|
|
|
811
|
+
// Strip the surrounding quotes from a STRING token and unescape the control
|
|
812
|
+
// sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
|
|
813
|
+
// left-to-right pass over each \x pair avoids the sequential-replace bug where
|
|
814
|
+
// an escaped backslash (\\) followed by n would be misread as a newline.
|
|
815
|
+
const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
|
|
816
|
+
const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
|
|
817
|
+
|
|
811
818
|
// Build an { instruments: { <key>: { name, shortName? } } } fragment from a
|
|
812
819
|
// `[instrument-<key>` header token. <key> is a staff-layout group key — a single
|
|
813
820
|
// staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
|
|
@@ -130,6 +130,13 @@
|
|
|
130
130
|
const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
|
|
131
131
|
const markupMark = (content) => ({ markType: 'markup', content });
|
|
132
132
|
|
|
133
|
+
// Strip the surrounding quotes from a STRING token and unescape the control
|
|
134
|
+
// sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
|
|
135
|
+
// left-to-right pass over each \x pair avoids the sequential-replace bug where
|
|
136
|
+
// an escaped backslash (\\) followed by n would be misread as a newline.
|
|
137
|
+
const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
|
|
138
|
+
const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
|
|
139
|
+
|
|
133
140
|
// Build an { instruments: { <key>: { name, shortName? } } } fragment from a
|
|
134
141
|
// `[instrument-<key>` header token. <key> is a staff-layout group key — a single
|
|
135
142
|
// staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
|
|
@@ -391,16 +398,16 @@ headers
|
|
|
391
398
|
;
|
|
392
399
|
|
|
393
400
|
header
|
|
394
|
-
: HEADER_TITLE STRING ']' -> ({ title: $2
|
|
395
|
-
| HEADER_SUBTITLE STRING ']' -> ({ subtitle: $2
|
|
396
|
-
| HEADER_COMPOSER STRING ']' -> ({ composer: $2
|
|
397
|
-
| HEADER_ARRANGER STRING ']' -> ({ arranger: $2
|
|
398
|
-
| HEADER_LYRICIST STRING ']' -> ({ lyricist: $2
|
|
399
|
-
| HEADER_OPUS STRING ']' -> ({ opus: $2
|
|
400
|
-
| HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2
|
|
401
|
-
| HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, $2
|
|
402
|
-
| HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2
|
|
403
|
-
| HEADER_GENRE STRING ']' -> ({ genre: $2
|
|
401
|
+
: HEADER_TITLE STRING ']' -> ({ title: unq($2) })
|
|
402
|
+
| HEADER_SUBTITLE STRING ']' -> ({ subtitle: unq($2) })
|
|
403
|
+
| HEADER_COMPOSER STRING ']' -> ({ composer: unq($2) })
|
|
404
|
+
| HEADER_ARRANGER STRING ']' -> ({ arranger: unq($2) })
|
|
405
|
+
| HEADER_LYRICIST STRING ']' -> ({ lyricist: unq($2) })
|
|
406
|
+
| HEADER_OPUS STRING ']' -> ({ opus: unq($2) })
|
|
407
|
+
| HEADER_INSTRUMENT STRING ']' -> ({ instrument: unq($2) })
|
|
408
|
+
| HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, unq($2))
|
|
409
|
+
| HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, unq($2), unq($3))
|
|
410
|
+
| HEADER_GENRE STRING ']' -> ({ genre: unq($2) })
|
|
404
411
|
| HEADER_STAVES STRING ']' -> ({ staves: $2.slice(1, -1) })
|
|
405
412
|
| HEADER_MEASURES STRING ']' -> ({ measureLayout: $2.slice(1, -1) })
|
|
406
413
|
| HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
|
|
@@ -486,9 +493,9 @@ harmony_event
|
|
|
486
493
|
;
|
|
487
494
|
|
|
488
495
|
markup_event
|
|
489
|
-
: CMD_MARKUP STRING -> markupEvent($2
|
|
490
|
-
| '^' CMD_MARKUP STRING -> markupEvent($3
|
|
491
|
-
| '_' CMD_MARKUP STRING -> markupEvent($3
|
|
496
|
+
: CMD_MARKUP STRING -> markupEvent(unq($2))
|
|
497
|
+
| '^' CMD_MARKUP STRING -> markupEvent(unq($3), 'above')
|
|
498
|
+
| '_' CMD_MARKUP STRING -> markupEvent(unq($3), 'below')
|
|
492
499
|
;
|
|
493
500
|
|
|
494
501
|
dynamic_event
|
|
@@ -606,8 +613,8 @@ partial_cmd
|
|
|
606
613
|
;
|
|
607
614
|
|
|
608
615
|
tempo_cmd
|
|
609
|
-
: CMD_TEMPO STRING duration '=' NUMBER -> ({ text: $2
|
|
610
|
-
| CMD_TEMPO STRING -> ({ text: $2
|
|
616
|
+
: CMD_TEMPO STRING duration '=' NUMBER -> ({ text: unq($2), beat: $3, bpm: Number($5) })
|
|
617
|
+
| CMD_TEMPO STRING -> ({ text: unq($2) })
|
|
611
618
|
| CMD_TEMPO duration '=' NUMBER -> ({ beat: $2, bpm: Number($4) })
|
|
612
619
|
;
|
|
613
620
|
|
|
@@ -769,5 +776,5 @@ navigation_mark
|
|
|
769
776
|
;
|
|
770
777
|
|
|
771
778
|
markup_mark
|
|
772
|
-
: CMD_MARKUP STRING -> markupMark($2
|
|
779
|
+
: CMD_MARKUP STRING -> markupMark(unq($2))
|
|
773
780
|
;
|
|
@@ -751,26 +751,30 @@ const encodeVoice = (
|
|
|
751
751
|
const encodeMetadata = (metadata: Metadata): string => {
|
|
752
752
|
const entries: string[] = [];
|
|
753
753
|
|
|
754
|
+
// LilyPond strings escape backslash and double-quote with a backslash. An
|
|
755
|
+
// unescaped " (or \) inside a header value otherwise breaks the \header block.
|
|
756
|
+
const ly = (s: string): string => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
757
|
+
|
|
754
758
|
if (metadata.title) {
|
|
755
|
-
entries.push(` title = "${metadata.title}"`);
|
|
759
|
+
entries.push(` title = "${ly(metadata.title)}"`);
|
|
756
760
|
}
|
|
757
761
|
if (metadata.subtitle) {
|
|
758
|
-
entries.push(` subtitle = "${metadata.subtitle}"`);
|
|
762
|
+
entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
|
|
759
763
|
}
|
|
760
764
|
if (metadata.composer) {
|
|
761
|
-
entries.push(` composer = "${metadata.composer}"`);
|
|
765
|
+
entries.push(` composer = "${ly(metadata.composer)}"`);
|
|
762
766
|
}
|
|
763
767
|
if (metadata.arranger) {
|
|
764
|
-
entries.push(` arranger = "${metadata.arranger}"`);
|
|
768
|
+
entries.push(` arranger = "${ly(metadata.arranger)}"`);
|
|
765
769
|
}
|
|
766
770
|
if (metadata.lyricist) {
|
|
767
|
-
entries.push(` poet = "${metadata.lyricist}"`);
|
|
771
|
+
entries.push(` poet = "${ly(metadata.lyricist)}"`);
|
|
768
772
|
}
|
|
769
773
|
if (metadata.opus) {
|
|
770
|
-
entries.push(` opus = "${metadata.opus}"`);
|
|
774
|
+
entries.push(` opus = "${ly(metadata.opus)}"`);
|
|
771
775
|
}
|
|
772
776
|
if (metadata.instrument) {
|
|
773
|
-
entries.push(` instrument = "${metadata.instrument}"`);
|
|
777
|
+
entries.push(` instrument = "${ly(metadata.instrument)}"`);
|
|
774
778
|
}
|
|
775
779
|
|
|
776
780
|
entries.push(' tagline = ##f');
|
|
@@ -412,6 +412,98 @@ export const collectVoltaSpans = (layout: MeasureLayout): VoltaEndingSpan[] => {
|
|
|
412
412
|
};
|
|
413
413
|
|
|
414
414
|
|
|
415
|
+
// ── Segment decomposition (for MEI nested sections + section-level expansion) ──
|
|
416
|
+
// Verovio draws volta house brackets only from <ending> containers, and only
|
|
417
|
+
// plays an <expansion> correctly when the plist references SECTION/ENDING ids
|
|
418
|
+
// (not bare measures) over a properly nested structure. So decompose the layout
|
|
419
|
+
// into contiguous measure groups — a "section" for plain runs and volta bodies,
|
|
420
|
+
// an "ending" for each volta alternate — and a performance order over those
|
|
421
|
+
// segment ids (a body segment id repeats once per pass). Returns null when the
|
|
422
|
+
// layout has no voltas (caller uses the simpler flat measure-level expansion) or
|
|
423
|
+
// can't be cleanly decomposed (non-contiguous segment → fall back to flat).
|
|
424
|
+
|
|
425
|
+
export interface LayoutSegment { id: string; kind: "section" | "ending"; endingNumber?: number; measures: number[]; }
|
|
426
|
+
export interface SegmentDecomposition { segments: LayoutSegment[]; order: string[]; }
|
|
427
|
+
|
|
428
|
+
export const decomposeToSegments = (layout: MeasureLayout): SegmentDecomposition | null => {
|
|
429
|
+
const segments: LayoutSegment[] = [];
|
|
430
|
+
let hasEnding = false;
|
|
431
|
+
let counter = 0;
|
|
432
|
+
const newId = (k: string) => `seg-${k}-${counter++}`;
|
|
433
|
+
|
|
434
|
+
// Indices a node covers in notation order (each measure once, ascending).
|
|
435
|
+
const indicesOf = (node: MeasureLayout): number[] => {
|
|
436
|
+
switch (node.kind) {
|
|
437
|
+
case "single": return [node.measure];
|
|
438
|
+
case "block": return ([] as number[]).concat(...node.seq.map(indicesOf));
|
|
439
|
+
case "volta": return ([] as number[]).concat(...node.body.map(indicesOf),
|
|
440
|
+
...(node.alternates ? ([] as number[]).concat(...node.alternates.map(a => ([] as number[]).concat(...a.map(indicesOf)))) : []));
|
|
441
|
+
case "aba": return ([] as number[]).concat(indicesOf(node.main), ...node.rest.map(indicesOf));
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
const contiguousAsc = (xs: number[]): boolean => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
|
|
445
|
+
|
|
446
|
+
// Accumulate a run of plain (non-volta/non-aba) measures into one section,
|
|
447
|
+
// flushing whenever a structural node (volta/aba) interrupts the run.
|
|
448
|
+
let run: number[] = [];
|
|
449
|
+
const flushRun = (): void => {
|
|
450
|
+
if (run.length === 0) return;
|
|
451
|
+
if (!contiguousAsc(run)) throw new Error("non-contiguous section run");
|
|
452
|
+
segments.push({ id: newId("sec"), kind: "section", measures: run });
|
|
453
|
+
run = [];
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Emit segments for a node in document order. Plain singles/blocks extend the
|
|
457
|
+
// current run; a volta flushes the run then emits body-section + ending(s); an
|
|
458
|
+
// aba flushes then recurses into main and rest (so the replayed main is its own
|
|
459
|
+
// segment run). Throws on any non-contiguous group → caller falls back to flat.
|
|
460
|
+
const emit = (node: MeasureLayout): void => {
|
|
461
|
+
switch (node.kind) {
|
|
462
|
+
case "single": run.push(node.measure); break;
|
|
463
|
+
case "block": node.seq.forEach(emit); break;
|
|
464
|
+
case "aba": flushRun(); emit(node.main); flushRun(); node.rest.forEach(emit); flushRun(); break;
|
|
465
|
+
case "volta": {
|
|
466
|
+
flushRun();
|
|
467
|
+
const body = indicesOf({ kind: "block", seq: node.body });
|
|
468
|
+
if (!contiguousAsc(body)) throw new Error("non-contiguous volta body");
|
|
469
|
+
segments.push({ id: newId("body"), kind: "section", measures: body });
|
|
470
|
+
if (node.alternates) {
|
|
471
|
+
hasEnding = true;
|
|
472
|
+
node.alternates.forEach((alt, ai) => {
|
|
473
|
+
const m = indicesOf({ kind: "block", seq: alt });
|
|
474
|
+
if (!contiguousAsc(m)) throw new Error("non-contiguous ending");
|
|
475
|
+
segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
try { emit(layout); flushRun(); } catch { return null; }
|
|
484
|
+
if (!hasEnding) return null; // no voltas → flat measure-level path is fine
|
|
485
|
+
|
|
486
|
+
// Map each measure index to the segment whose `measures` contains it. A volta
|
|
487
|
+
// body measure belongs to its body section; alternate measures to their ending.
|
|
488
|
+
const segOfMeasure = new Map<number, string>();
|
|
489
|
+
for (const s of segments) for (const m of s.measures) if (!segOfMeasure.has(m)) segOfMeasure.set(m, s.id);
|
|
490
|
+
|
|
491
|
+
// Performance order over segment ids: walk the flat Full expansion and collapse
|
|
492
|
+
// consecutive measures that map to the same segment into one segment ref. This
|
|
493
|
+
// reproduces the exact playback order (body repeats → body id appears twice,
|
|
494
|
+
// ABA replay → main segment id reappears), keyed off the proven expander.
|
|
495
|
+
const flat = expandMeasureLayout(layout, LayoutType.Full);
|
|
496
|
+
const order: string[] = [];
|
|
497
|
+
for (const m of flat) {
|
|
498
|
+
const id = segOfMeasure.get(m);
|
|
499
|
+
if (id === undefined) return null; // a played measure isn't in any segment → bail to flat
|
|
500
|
+
if (order.length === 0 || order[order.length - 1] !== id) order.push(id);
|
|
501
|
+
else if (segments.find(s => s.id === id)!.measures[0] === m) order.push(id); // same seg restarting (repeat) → new ref
|
|
502
|
+
}
|
|
503
|
+
return { segments, order };
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
|
|
415
507
|
// ── Serializer ───────────────────────────────────────────────────────────
|
|
416
508
|
// Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
|
|
417
509
|
// "A..B" ranges (lotus's seqToCode).
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from "./types";
|
|
30
30
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
31
|
import { gmProgramOf } from "./gmInstruments";
|
|
32
|
-
import { parseMeasureLayout, expandMeasureLayout,
|
|
32
|
+
import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -2744,27 +2744,68 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2744
2744
|
const expansionInsertPos = mei.length;
|
|
2745
2745
|
const measureIds: string[] = [];
|
|
2746
2746
|
|
|
2747
|
-
// Volta
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
//
|
|
2753
|
-
|
|
2754
|
-
const
|
|
2747
|
+
// Volta playback + house brackets. A flat measure-level <expansion> conflicts
|
|
2748
|
+
// with <ending> wrappers in verovio (it then drops the volta measures). Verovio
|
|
2749
|
+
// instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
|
|
2750
|
+
// whose plist references those segment ids. So when the layout decomposes into
|
|
2751
|
+
// volta segments, emit that structure; otherwise keep the simpler flat path.
|
|
2752
|
+
// See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
|
|
2753
|
+
// Helper to check if a measure has any musical content
|
|
2754
|
+
const measureHasContent = (measure: Measure): boolean => {
|
|
2755
|
+
for (const part of measure.parts) {
|
|
2756
|
+
for (const voice of part.voices) {
|
|
2757
|
+
for (const event of voice.events) {
|
|
2758
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
2759
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
2760
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2761
|
+
return true;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
return false;
|
|
2767
|
+
};
|
|
2768
|
+
|
|
2769
|
+
// Filter out trailing empty measures (done before segment setup so the segment
|
|
2770
|
+
// guard below knows the true emitted measure count).
|
|
2771
|
+
let measures = doc.measures;
|
|
2772
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2773
|
+
measures = measures.slice(0, -1);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
let segDecomp: ReturnType<typeof decomposeToSegments> = null;
|
|
2777
|
+
const segOpenAt = new Map<number, string>(); // measure index → opening tag for its segment
|
|
2778
|
+
const segCloseAt = new Map<number, string>(); // measure index → closing tag after it
|
|
2779
|
+
const segIdById = new Map<string, string>(); // layout segment id → emitted xml:id
|
|
2755
2780
|
if (doc.metadata?.measureLayout) {
|
|
2756
2781
|
try {
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2782
|
+
segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2783
|
+
} catch { segDecomp = null; }
|
|
2784
|
+
}
|
|
2785
|
+
// A layout that references a measure beyond the emitted range (e.g. a volta
|
|
2786
|
+
// alternate whose bar was a trailing empty measure that got trimmed) cannot be
|
|
2787
|
+
// emitted as nested sections without a dangling <ending>/<section> id or an
|
|
2788
|
+
// unbalanced wrapper. Drop to the flat measure-level expansion path, which
|
|
2789
|
+
// already warns-and-skips out-of-range refs.
|
|
2790
|
+
if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
|
|
2791
|
+
segDecomp = null;
|
|
2792
|
+
}
|
|
2793
|
+
if (segDecomp) {
|
|
2794
|
+
const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2795
|
+
for (const seg of segDecomp.segments) {
|
|
2796
|
+
const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
|
|
2797
|
+
segIdById.set(seg.id, xmlId);
|
|
2798
|
+
const lo = seg.measures[0];
|
|
2799
|
+
const hi = seg.measures[seg.measures.length - 1];
|
|
2800
|
+
if (seg.kind === "ending") {
|
|
2801
|
+
const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
|
|
2802
|
+
segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
|
|
2803
|
+
} else {
|
|
2804
|
+
segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
|
|
2764
2805
|
}
|
|
2765
|
-
|
|
2806
|
+
segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
|
|
2807
|
+
}
|
|
2766
2808
|
}
|
|
2767
|
-
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2768
2809
|
|
|
2769
2810
|
// Track tie state across measures for cross-measure ties
|
|
2770
2811
|
const tieState: TieState = {};
|
|
@@ -2796,28 +2837,6 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2796
2837
|
}
|
|
2797
2838
|
}
|
|
2798
2839
|
|
|
2799
|
-
// Helper to check if a measure has any musical content
|
|
2800
|
-
const measureHasContent = (measure: Measure): boolean => {
|
|
2801
|
-
for (const part of measure.parts) {
|
|
2802
|
-
for (const voice of part.voices) {
|
|
2803
|
-
for (const event of voice.events) {
|
|
2804
|
-
// Check for actual musical content (not just context changes or pitch resets)
|
|
2805
|
-
if (event.type === 'note' || event.type === 'rest' ||
|
|
2806
|
-
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2807
|
-
return true;
|
|
2808
|
-
}
|
|
2809
|
-
}
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
return false;
|
|
2813
|
-
};
|
|
2814
|
-
|
|
2815
|
-
// Filter out trailing empty measures
|
|
2816
|
-
let measures = doc.measures;
|
|
2817
|
-
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2818
|
-
measures = measures.slice(0, -1);
|
|
2819
|
-
}
|
|
2820
|
-
|
|
2821
2840
|
// Encode measures
|
|
2822
2841
|
measures.forEach((measure, mi) => {
|
|
2823
2842
|
// Check for key signature change and output scoreDef if needed
|
|
@@ -2877,29 +2896,32 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2877
2896
|
}
|
|
2878
2897
|
const mIndex = mi + 1;
|
|
2879
2898
|
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2880
|
-
//
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
endingOpen = true;
|
|
2886
|
-
}
|
|
2887
|
-
const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
|
|
2899
|
+
// When the layout decomposes into volta segments, wrap each measure run in
|
|
2900
|
+
// its <section>/<ending> container (opened/closed by measure index).
|
|
2901
|
+
const openTag = segOpenAt.get(mIndex);
|
|
2902
|
+
if (openTag) mei += openTag;
|
|
2903
|
+
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2888
2904
|
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2889
|
-
|
|
2890
|
-
if (
|
|
2891
|
-
mei += `${mIndent}</ending>\n`;
|
|
2892
|
-
endingOpen = false;
|
|
2893
|
-
}
|
|
2905
|
+
const closeTag = segCloseAt.get(mIndex);
|
|
2906
|
+
if (closeTag) mei += closeTag;
|
|
2894
2907
|
});
|
|
2895
|
-
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2896
|
-
if (endingOpen) { mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`; endingOpen = false; }
|
|
2897
2908
|
|
|
2898
2909
|
// Emit an <expansion> describing playback order from the measure-layout
|
|
2899
|
-
// directive ([measures "..."]). Verovio
|
|
2900
|
-
//
|
|
2901
|
-
//
|
|
2902
|
-
|
|
2910
|
+
// directive ([measures "..."]). Verovio consumes its plist to unfold repeats
|
|
2911
|
+
// for MIDI/time-map output. Inserted as the first child of <section>.
|
|
2912
|
+
// - voltas present (segDecomp): plist references the SECTION/ENDING segment
|
|
2913
|
+
// ids in performed order (a body segment id repeats per pass). This is the
|
|
2914
|
+
// only form verovio plays correctly alongside <ending> house brackets.
|
|
2915
|
+
// - otherwise: the simpler flat measure-level plist (one ref per played bar).
|
|
2916
|
+
if (segDecomp) {
|
|
2917
|
+
const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x): x is string => x !== undefined).map(id => `#${id}`);
|
|
2918
|
+
if (refs.length > 0) {
|
|
2919
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2920
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2921
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
else if (doc.metadata?.measureLayout) {
|
|
2903
2925
|
try {
|
|
2904
2926
|
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2905
2927
|
const order = expandMeasureLayout(layout);
|
|
@@ -631,7 +631,7 @@ const serializeEvent = (
|
|
|
631
631
|
case 'markup': {
|
|
632
632
|
const mk = event as MarkupEvent;
|
|
633
633
|
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
634
|
-
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
634
|
+
return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
|
|
635
635
|
}
|
|
636
636
|
case 'dynamic': {
|
|
637
637
|
const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
|
|
@@ -1011,7 +1011,12 @@ const serializeMeasure = (
|
|
|
1011
1011
|
|
|
1012
1012
|
// Escape string for serialization (quotes and backslashes)
|
|
1013
1013
|
const escapeString = (str: string): string => {
|
|
1014
|
-
return str
|
|
1014
|
+
return str
|
|
1015
|
+
.replace(/\\/g, '\\\\')
|
|
1016
|
+
.replace(/"/g, '\\"')
|
|
1017
|
+
.replace(/\n/g, '\\n')
|
|
1018
|
+
.replace(/\r/g, '\\r')
|
|
1019
|
+
.replace(/\t/g, '\\t');
|
|
1015
1020
|
};
|
|
1016
1021
|
|
|
1017
1022
|
// Serialize metadata
|