@k-l-lambda/lilylet 0.1.75 → 0.1.78
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/abc/grammar.jison.js +85 -79
- package/lib/lilylet/abcDecoder.js +167 -0
- package/lib/lilylet/grammar.jison.js +143 -125
- package/lib/lilylet/highlight.js +3 -1
- 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/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +146 -70
- package/lib/lilylet/musicXmlDecoder.js +119 -24
- package/lib/lilylet/musicXmlEncoder.js +85 -18
- package/lib/lilylet/musicXmlTypes.d.ts +3 -1
- package/lib/lilylet/musicXmlUtils.js +1 -1
- package/lib/lilylet/serializer.js +21 -2
- package/lib/lilylet/types.d.ts +6 -2
- package/lib/lilylet/types.js +1 -0
- package/package.json +3 -1
- package/source/abc/abc.jison +8 -4
- package/source/abc/grammar.jison.js +85 -79
- package/source/lilylet/abcDecoder.ts +189 -0
- package/source/lilylet/grammar.jison.js +143 -125
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +33 -17
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +92 -0
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +164 -66
- package/source/lilylet/musicXmlDecoder.ts +117 -23
- package/source/lilylet/musicXmlEncoder.ts +89 -16
- package/source/lilylet/musicXmlTypes.ts +5 -1
- package/source/lilylet/musicXmlUtils.ts +1 -1
- package/source/lilylet/serializer.ts +21 -2
- package/source/lilylet/types.ts +10 -1
|
@@ -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).
|
|
@@ -39,6 +39,7 @@ export interface MeasureRepeatInfo {
|
|
|
39
39
|
* guaranteed to expand (via measureLayout.ts) to the simulated performed order.
|
|
40
40
|
*/
|
|
41
41
|
export declare const buildMeasureLayout: (infos: MeasureRepeatInfo[], totalMeasures: number) => string | undefined;
|
|
42
|
+
export declare const applyNavText: (info: MeasureRepeatInfo, raw: string) => void;
|
|
42
43
|
export declare const collectRepeatInfo: (partEl: Element) => MeasureRepeatInfo[];
|
|
43
44
|
/**
|
|
44
45
|
* Derive metadata.measureLayout from a <part> element, or undefined if no
|
|
@@ -298,7 +298,9 @@ export const buildMeasureLayout = (infos, totalMeasures) => {
|
|
|
298
298
|
// "D.C. al Fine" → dacapo (the Fine stop is marked separately where "Fine"
|
|
299
299
|
// appears); "D.S. al Coda"/"D.S." → dalsegno; "To Coda" → tocoda; bare "Fine"
|
|
300
300
|
// → fine; bare "Coda"/"Segno" → mark the landing point.
|
|
301
|
-
|
|
301
|
+
// Exported so the ABC decoder can feed its !D.C.!/!D.S.!/!fine! decoration text
|
|
302
|
+
// through the SAME recognizer, keeping the ABC and MusicXML nav semantics identical.
|
|
303
|
+
export const applyNavText = (info, raw) => {
|
|
302
304
|
const t = raw.trim().toLowerCase().replace(/\s+/g, " ");
|
|
303
305
|
if (!t)
|
|
304
306
|
return;
|
|
@@ -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",
|
|
@@ -342,6 +342,8 @@ const extractMarkOptions = (marks) => {
|
|
|
342
342
|
fingerings: [],
|
|
343
343
|
navigation: undefined,
|
|
344
344
|
markups: [],
|
|
345
|
+
breath: false,
|
|
346
|
+
glissando: false,
|
|
345
347
|
};
|
|
346
348
|
if (!marks)
|
|
347
349
|
return result;
|
|
@@ -379,6 +381,9 @@ const extractMarkOptions = (marks) => {
|
|
|
379
381
|
else if (mark.type === OrnamentType.prall) {
|
|
380
382
|
result.mordent = 'upper';
|
|
381
383
|
}
|
|
384
|
+
else if (mark.type === OrnamentType.breath) {
|
|
385
|
+
result.breath = true;
|
|
386
|
+
}
|
|
382
387
|
break;
|
|
383
388
|
case 'dynamic': {
|
|
384
389
|
const dynStr = DYNAMIC_MAP[mark.type];
|
|
@@ -449,6 +454,9 @@ const extractMarkOptions = (marks) => {
|
|
|
449
454
|
placement: mark.placement,
|
|
450
455
|
});
|
|
451
456
|
break;
|
|
457
|
+
case 'glissando':
|
|
458
|
+
result.glissando = true;
|
|
459
|
+
break;
|
|
452
460
|
}
|
|
453
461
|
// Tremolo (special case - from parser internal mark)
|
|
454
462
|
if ('tremolo' in mark && typeof mark.tremolo === 'number') {
|
|
@@ -514,6 +522,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
514
522
|
fingerings: markOptions.fingerings,
|
|
515
523
|
navigation: markOptions.navigation,
|
|
516
524
|
markups: markOptions.markups,
|
|
525
|
+
breath: markOptions.breath,
|
|
526
|
+
glissando: markOptions.glissando,
|
|
517
527
|
};
|
|
518
528
|
}
|
|
519
529
|
// Chord
|
|
@@ -576,6 +586,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
576
586
|
fingerings: markOptions.fingerings,
|
|
577
587
|
navigation: markOptions.navigation,
|
|
578
588
|
markups: markOptions.markups,
|
|
589
|
+
breath: markOptions.breath,
|
|
590
|
+
glissando: markOptions.glissando,
|
|
579
591
|
};
|
|
580
592
|
};
|
|
581
593
|
// Convert RestEvent to MEI
|
|
@@ -846,7 +858,7 @@ const getEventBeamMarks = (event) => {
|
|
|
846
858
|
return { beamStart: false, beamEnd: false };
|
|
847
859
|
};
|
|
848
860
|
// Encode a layer (voice)
|
|
849
|
-
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null) => {
|
|
861
|
+
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null, initialGliss = null) => {
|
|
850
862
|
const layerId = generateId("layer");
|
|
851
863
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
852
864
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
@@ -921,6 +933,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
921
933
|
// Track ornament refs
|
|
922
934
|
const fermatas = [];
|
|
923
935
|
const trills = [];
|
|
936
|
+
const breaths = [];
|
|
937
|
+
// Glissando: a note-to-note slide. Record the start note id and close it on the
|
|
938
|
+
// NEXT encoded note (endid), mirroring slur start→end pairing. A trailing
|
|
939
|
+
// glissando with no following note in the layer is dropped (no target).
|
|
940
|
+
const glissandos = [];
|
|
941
|
+
let pendingGlissStart = initialGliss;
|
|
924
942
|
const mordents = [];
|
|
925
943
|
const turns = [];
|
|
926
944
|
const dynamics = [];
|
|
@@ -1082,6 +1100,19 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1082
1100
|
if (result.trill) {
|
|
1083
1101
|
trills.push({ startid: result.elementId });
|
|
1084
1102
|
}
|
|
1103
|
+
// Breath mark follows this note.
|
|
1104
|
+
if (result.breath) {
|
|
1105
|
+
breaths.push({ startid: result.elementId });
|
|
1106
|
+
}
|
|
1107
|
+
// Glissando: close a pending start onto this note, then open a new one
|
|
1108
|
+
// if this note itself carries a glissando.
|
|
1109
|
+
if (pendingGlissStart) {
|
|
1110
|
+
glissandos.push({ startid: pendingGlissStart, endid: result.elementId });
|
|
1111
|
+
pendingGlissStart = null;
|
|
1112
|
+
}
|
|
1113
|
+
if (result.glissando) {
|
|
1114
|
+
pendingGlissStart = result.elementId;
|
|
1115
|
+
}
|
|
1085
1116
|
if (result.mordent) {
|
|
1086
1117
|
mordents.push({
|
|
1087
1118
|
startid: result.elementId,
|
|
@@ -1350,10 +1381,10 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1350
1381
|
}
|
|
1351
1382
|
}
|
|
1352
1383
|
xml += `${indent}</layer>\n`;
|
|
1353
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
|
|
1384
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, breaths, glissandos, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingGliss: pendingGlissStart, pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
|
|
1354
1385
|
};
|
|
1355
1386
|
// Encode a staff
|
|
1356
|
-
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
|
|
1387
|
+
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef, glissState = {}) => {
|
|
1357
1388
|
const staffId = generateId("staff");
|
|
1358
1389
|
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
1359
1390
|
const allHairpins = [];
|
|
@@ -1363,6 +1394,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1363
1394
|
const allArpeggios = [];
|
|
1364
1395
|
const allFermatas = [];
|
|
1365
1396
|
const allTrills = [];
|
|
1397
|
+
const allBreaths = [];
|
|
1398
|
+
const allGlissandos = [];
|
|
1366
1399
|
const allMordents = [];
|
|
1367
1400
|
const allTurns = [];
|
|
1368
1401
|
const allDynamics = [];
|
|
@@ -1373,6 +1406,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1373
1406
|
const allMarkups = [];
|
|
1374
1407
|
const pendingTies = {};
|
|
1375
1408
|
const pendingSlurs = {};
|
|
1409
|
+
const pendingGlisses = {};
|
|
1376
1410
|
const pendingHairpins = {};
|
|
1377
1411
|
const pendingOctaves = {};
|
|
1378
1412
|
const ottavaExplicitlyClosed = {};
|
|
@@ -1390,7 +1424,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1390
1424
|
const initialSlur = slurState[tieKey] || [];
|
|
1391
1425
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1392
1426
|
const initialOctave = ottavaState[tieKey] || null;
|
|
1393
|
-
const
|
|
1427
|
+
const initialGliss = glissState[tieKey] || null;
|
|
1428
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave, initialGliss);
|
|
1394
1429
|
xml += result.xml;
|
|
1395
1430
|
allHairpins.push(...result.hairpins);
|
|
1396
1431
|
allPedals.push(...result.pedals);
|
|
@@ -1399,6 +1434,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1399
1434
|
allArpeggios.push(...result.arpeggios);
|
|
1400
1435
|
allFermatas.push(...result.fermatas);
|
|
1401
1436
|
allTrills.push(...result.trills);
|
|
1437
|
+
allBreaths.push(...result.breaths);
|
|
1438
|
+
allGlissandos.push(...result.glissandos);
|
|
1402
1439
|
allMordents.push(...result.mordents);
|
|
1403
1440
|
allTurns.push(...result.turns);
|
|
1404
1441
|
allDynamics.push(...result.dynamics);
|
|
@@ -1416,6 +1453,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1416
1453
|
// Always record (even empty) so a measure that closed all its slurs
|
|
1417
1454
|
// clears the carried stack rather than leaving a stale open slur.
|
|
1418
1455
|
pendingSlurs[tieKey] = result.pendingSlur;
|
|
1456
|
+
pendingGlisses[tieKey] = result.pendingGliss;
|
|
1419
1457
|
// Track pending hairpins for this layer
|
|
1420
1458
|
if (result.pendingHairpin) {
|
|
1421
1459
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
@@ -1446,6 +1484,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1446
1484
|
arpeggios: allArpeggios,
|
|
1447
1485
|
fermatas: allFermatas,
|
|
1448
1486
|
trills: allTrills,
|
|
1487
|
+
breaths: allBreaths,
|
|
1488
|
+
glissandos: allGlissandos,
|
|
1449
1489
|
mordents: allMordents,
|
|
1450
1490
|
turns: allTurns,
|
|
1451
1491
|
dynamics: allDynamics,
|
|
@@ -1456,6 +1496,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1456
1496
|
markups: allMarkups,
|
|
1457
1497
|
pendingTies,
|
|
1458
1498
|
pendingSlurs,
|
|
1499
|
+
pendingGlisses,
|
|
1459
1500
|
pendingHairpins,
|
|
1460
1501
|
pendingOctaves,
|
|
1461
1502
|
ottavaExplicitlyClosed,
|
|
@@ -1566,7 +1607,7 @@ const BARLINE_TO_MEI = {
|
|
|
1566
1607
|
};
|
|
1567
1608
|
// Encode a measure
|
|
1568
1609
|
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1569
|
-
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
|
|
1610
|
+
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds, glissState = {}) => {
|
|
1570
1611
|
const measureId = generateId("measure");
|
|
1571
1612
|
if (measureIds)
|
|
1572
1613
|
measureIds.push(measureId);
|
|
@@ -1578,6 +1619,8 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1578
1619
|
const allArpeggios = [];
|
|
1579
1620
|
const allFermatas = [];
|
|
1580
1621
|
const allTrills = [];
|
|
1622
|
+
const allBreaths = [];
|
|
1623
|
+
const allGlissandos = [];
|
|
1581
1624
|
const allMordents = [];
|
|
1582
1625
|
const allTurns = [];
|
|
1583
1626
|
const allDynamics = [];
|
|
@@ -1624,7 +1667,7 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1624
1667
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1625
1668
|
const voices = voicesByStaff[si] || [];
|
|
1626
1669
|
const initialClef = clefState[si];
|
|
1627
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1670
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef, glissState);
|
|
1628
1671
|
staffContent += result.xml;
|
|
1629
1672
|
allHairpins.push(...result.hairpins);
|
|
1630
1673
|
allPedals.push(...result.pedals);
|
|
@@ -1633,6 +1676,8 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1633
1676
|
allArpeggios.push(...result.arpeggios);
|
|
1634
1677
|
allFermatas.push(...result.fermatas);
|
|
1635
1678
|
allTrills.push(...result.trills);
|
|
1679
|
+
allBreaths.push(...result.breaths);
|
|
1680
|
+
allGlissandos.push(...result.glissandos);
|
|
1636
1681
|
allMordents.push(...result.mordents);
|
|
1637
1682
|
allTurns.push(...result.turns);
|
|
1638
1683
|
allDynamics.push(...result.dynamics);
|
|
@@ -1646,6 +1691,7 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1646
1691
|
Object.assign(tieState, result.pendingTies);
|
|
1647
1692
|
// Update slur state with pending slurs from this staff
|
|
1648
1693
|
Object.assign(slurState, result.pendingSlurs);
|
|
1694
|
+
Object.assign(glissState, result.pendingGlisses);
|
|
1649
1695
|
// Update hairpin state with pending hairpins from this staff
|
|
1650
1696
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1651
1697
|
// Update ottava state with pending octaves from this staff.
|
|
@@ -1718,6 +1764,14 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1718
1764
|
for (const tr of allTrills) {
|
|
1719
1765
|
staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
|
|
1720
1766
|
}
|
|
1767
|
+
// Generate breath marks (the breath follows its startid note)
|
|
1768
|
+
for (const br of allBreaths) {
|
|
1769
|
+
staffContent += `${indent} <breath xml:id="${generateId('breath')}" startid="#${br.startid}" />\n`;
|
|
1770
|
+
}
|
|
1771
|
+
// Generate glissando spans (note-to-note slide)
|
|
1772
|
+
for (const gl of allGlissandos) {
|
|
1773
|
+
staffContent += `${indent} <gliss xml:id="${generateId('gliss')}" startid="#${gl.startid}" endid="#${gl.endid}" />\n`;
|
|
1774
|
+
}
|
|
1721
1775
|
// Generate mordent control events
|
|
1722
1776
|
for (const mord of allMordents) {
|
|
1723
1777
|
const formAttr = mord.form ? ` form="${mord.form}"` : '';
|
|
@@ -2294,35 +2348,75 @@ const encode = (doc, options = {}) => {
|
|
|
2294
2348
|
// below — so remember where to insert it and build it afterwards).
|
|
2295
2349
|
const expansionInsertPos = mei.length;
|
|
2296
2350
|
const measureIds = [];
|
|
2297
|
-
// Volta
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
|
|
2304
|
-
const
|
|
2351
|
+
// Volta playback + house brackets. A flat measure-level <expansion> conflicts
|
|
2352
|
+
// with <ending> wrappers in verovio (it then drops the volta measures). Verovio
|
|
2353
|
+
// instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
|
|
2354
|
+
// whose plist references those segment ids. So when the layout decomposes into
|
|
2355
|
+
// volta segments, emit that structure; otherwise keep the simpler flat path.
|
|
2356
|
+
// See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
|
|
2357
|
+
// Helper to check if a measure has any musical content
|
|
2358
|
+
const measureHasContent = (measure) => {
|
|
2359
|
+
for (const part of measure.parts) {
|
|
2360
|
+
for (const voice of part.voices) {
|
|
2361
|
+
for (const event of voice.events) {
|
|
2362
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
2363
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
2364
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2365
|
+
return true;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return false;
|
|
2371
|
+
};
|
|
2372
|
+
// Filter out trailing empty measures (done before segment setup so the segment
|
|
2373
|
+
// guard below knows the true emitted measure count).
|
|
2374
|
+
let measures = doc.measures;
|
|
2375
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2376
|
+
measures = measures.slice(0, -1);
|
|
2377
|
+
}
|
|
2378
|
+
let segDecomp = null;
|
|
2379
|
+
const segOpenAt = new Map(); // measure index → opening tag for its segment
|
|
2380
|
+
const segCloseAt = new Map(); // measure index → closing tag after it
|
|
2381
|
+
const segIdById = new Map(); // layout segment id → emitted xml:id
|
|
2305
2382
|
if (doc.metadata?.measureLayout) {
|
|
2306
2383
|
try {
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2384
|
+
segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2385
|
+
}
|
|
2386
|
+
catch {
|
|
2387
|
+
segDecomp = null;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
// A layout that references a measure beyond the emitted range (e.g. a volta
|
|
2391
|
+
// alternate whose bar was a trailing empty measure that got trimmed) cannot be
|
|
2392
|
+
// emitted as nested sections without a dangling <ending>/<section> id or an
|
|
2393
|
+
// unbalanced wrapper. Drop to the flat measure-level expansion path, which
|
|
2394
|
+
// already warns-and-skips out-of-range refs.
|
|
2395
|
+
if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
|
|
2396
|
+
segDecomp = null;
|
|
2397
|
+
}
|
|
2398
|
+
if (segDecomp) {
|
|
2399
|
+
const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2400
|
+
for (const seg of segDecomp.segments) {
|
|
2401
|
+
const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
|
|
2402
|
+
segIdById.set(seg.id, xmlId);
|
|
2403
|
+
const lo = seg.measures[0];
|
|
2404
|
+
const hi = seg.measures[seg.measures.length - 1];
|
|
2405
|
+
if (seg.kind === "ending") {
|
|
2406
|
+
const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
|
|
2407
|
+
segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
|
|
2408
|
+
}
|
|
2409
|
+
else {
|
|
2410
|
+
segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
|
|
2317
2411
|
}
|
|
2412
|
+
segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
|
|
2318
2413
|
}
|
|
2319
|
-
catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
|
|
2320
2414
|
}
|
|
2321
|
-
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2322
2415
|
// Track tie state across measures for cross-measure ties
|
|
2323
2416
|
const tieState = {};
|
|
2324
2417
|
// Track slur state across measures for cross-measure slurs
|
|
2325
2418
|
const slurState = {};
|
|
2419
|
+
const glissState = {};
|
|
2326
2420
|
// Track hairpin state across measures for cross-measure hairpins
|
|
2327
2421
|
const hairpinState = {};
|
|
2328
2422
|
// Track ottava state across measures for cross-measure ottava spans
|
|
@@ -2344,26 +2438,6 @@ const encode = (doc, options = {}) => {
|
|
|
2344
2438
|
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2345
2439
|
}
|
|
2346
2440
|
}
|
|
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
2441
|
// Encode measures
|
|
2368
2442
|
measures.forEach((measure, mi) => {
|
|
2369
2443
|
// Check for key signature change and output scoreDef if needed
|
|
@@ -2427,31 +2501,33 @@ const encode = (doc, options = {}) => {
|
|
|
2427
2501
|
}
|
|
2428
2502
|
const mIndex = mi + 1;
|
|
2429
2503
|
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2430
|
-
//
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
mei +=
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
const
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
if (endingOpen && endingCloseAt.has(mIndex)) {
|
|
2441
|
-
mei += `${mIndent}</ending>\n`;
|
|
2442
|
-
endingOpen = false;
|
|
2443
|
-
}
|
|
2504
|
+
// When the layout decomposes into volta segments, wrap each measure run in
|
|
2505
|
+
// its <section>/<ending> container (opened/closed by measure index).
|
|
2506
|
+
const openTag = segOpenAt.get(mIndex);
|
|
2507
|
+
if (openTag)
|
|
2508
|
+
mei += openTag;
|
|
2509
|
+
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2510
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
|
|
2511
|
+
const closeTag = segCloseAt.get(mIndex);
|
|
2512
|
+
if (closeTag)
|
|
2513
|
+
mei += closeTag;
|
|
2444
2514
|
});
|
|
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
2515
|
// Emit an <expansion> describing playback order from the measure-layout
|
|
2451
|
-
// directive ([measures "..."]). Verovio
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
|
|
2516
|
+
// directive ([measures "..."]). Verovio consumes its plist to unfold repeats
|
|
2517
|
+
// for MIDI/time-map output. Inserted as the first child of <section>.
|
|
2518
|
+
// - voltas present (segDecomp): plist references the SECTION/ENDING segment
|
|
2519
|
+
// ids in performed order (a body segment id repeats per pass). This is the
|
|
2520
|
+
// only form verovio plays correctly alongside <ending> house brackets.
|
|
2521
|
+
// - otherwise: the simpler flat measure-level plist (one ref per played bar).
|
|
2522
|
+
if (segDecomp) {
|
|
2523
|
+
const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x) => x !== undefined).map(id => `#${id}`);
|
|
2524
|
+
if (refs.length > 0) {
|
|
2525
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2526
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2527
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
else if (doc.metadata?.measureLayout) {
|
|
2455
2531
|
try {
|
|
2456
2532
|
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2457
2533
|
const order = expandMeasureLayout(layout);
|