@k-l-lambda/lilylet 0.1.74 → 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 +200 -188
- package/lib/lilylet/highlight.js +1 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/lilypondEncoder.js +10 -7
- package/lib/lilylet/measureLayout.d.ts +79 -0
- package/lib/lilylet/measureLayout.js +509 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +201 -60
- package/lib/lilylet/musicXmlDecoder.js +172 -6
- package/lib/lilylet/musicXmlTypes.d.ts +9 -0
- package/lib/lilylet/serializer.js +131 -12
- package/lib/lilylet/types.d.ts +1 -0
- package/lib/measureLayout.d.ts +1 -0
- package/lib/measureLayout.js +1 -0
- package/lib/measureLayoutFromXml.d.ts +1 -0
- package/lib/measureLayoutFromXml.js +1 -0
- package/package.json +5 -2
- package/source/lilylet/grammar.jison.js +200 -188
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +25 -16
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +552 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +193 -61
- package/source/lilylet/musicXmlDecoder.ts +169 -6
- package/source/lilylet/musicXmlTypes.ts +12 -0
- package/source/lilylet/serializer.ts +125 -12
- package/source/lilylet/types.ts +4 -0
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
createFraction,
|
|
70
70
|
TYPE_TO_DIVISION,
|
|
71
71
|
} from './musicXmlUtils';
|
|
72
|
+
import { measureLayoutFromPart } from './measureLayoutFromXml';
|
|
72
73
|
|
|
73
74
|
// ============ Spanner Tracker ============
|
|
74
75
|
|
|
@@ -630,6 +631,25 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
|
|
|
630
631
|
result.placement = getAttribute(dirEl, 'placement') as 'above' | 'below' | undefined;
|
|
631
632
|
result.staff = getElementInt(dirEl, 'staff');
|
|
632
633
|
|
|
634
|
+
// Navigation jumps live on the <sound> child of <direction> (a sibling of
|
|
635
|
+
// <direction-type>). Parse it first, before the no-direction-type early return,
|
|
636
|
+
// so a sound-only direction still contributes its jump semantics.
|
|
637
|
+
const soundEl = dirEl.getElementsByTagName('sound')[0];
|
|
638
|
+
if (soundEl) {
|
|
639
|
+
const sound: NonNullable<MusicXmlDirection['sound']> = {};
|
|
640
|
+
if (getAttribute(soundEl, 'dacapo') === 'yes') sound.dacapo = true;
|
|
641
|
+
if (getAttribute(soundEl, 'fine') === 'yes') sound.fine = true;
|
|
642
|
+
const dalsegno = getAttribute(soundEl, 'dalsegno');
|
|
643
|
+
if (dalsegno) sound.dalsegno = dalsegno;
|
|
644
|
+
const segno = getAttribute(soundEl, 'segno');
|
|
645
|
+
if (segno) sound.segno = segno;
|
|
646
|
+
const coda = getAttribute(soundEl, 'coda');
|
|
647
|
+
if (coda) sound.coda = coda;
|
|
648
|
+
const tocoda = getAttribute(soundEl, 'tocoda');
|
|
649
|
+
if (tocoda) sound.tocoda = tocoda;
|
|
650
|
+
if (Object.keys(sound).length > 0) result.sound = sound;
|
|
651
|
+
}
|
|
652
|
+
|
|
633
653
|
const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
|
|
634
654
|
if (!dirTypeEl) {
|
|
635
655
|
return result;
|
|
@@ -722,7 +742,8 @@ const parseBarline = (barlineEl: Element): MusicXmlBarline => {
|
|
|
722
742
|
if (repeatEl) {
|
|
723
743
|
const direction = getAttribute(repeatEl, 'direction') as 'forward' | 'backward';
|
|
724
744
|
if (direction) {
|
|
725
|
-
|
|
745
|
+
const times = getAttributeNumber(repeatEl, 'times');
|
|
746
|
+
result.repeat = times !== undefined ? { direction, times } : { direction };
|
|
726
747
|
}
|
|
727
748
|
}
|
|
728
749
|
|
|
@@ -1065,7 +1086,10 @@ const directionToMarks = (
|
|
|
1065
1086
|
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
1066
1087
|
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
1067
1088
|
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
1068
|
-
// directions are tempo too, never markup.
|
|
1089
|
+
// directions are tempo too, never markup. Navigation text ("D.C. al Fine",
|
|
1090
|
+
// "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
|
|
1091
|
+
// markup glyph — the jump SEMANTICS are captured separately as measure-layout.
|
|
1092
|
+
let emittedWords = false;
|
|
1069
1093
|
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
1070
1094
|
const text = direction.words.map(w => w.text).join('').trim();
|
|
1071
1095
|
if (text && !isTempoWord(text)) {
|
|
@@ -1073,6 +1097,27 @@ const directionToMarks = (
|
|
|
1073
1097
|
: direction.placement === 'below' ? Placement.below
|
|
1074
1098
|
: undefined;
|
|
1075
1099
|
marks.push({ markType: 'markup', content: text, placement });
|
|
1100
|
+
emittedWords = true;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
|
|
1105
|
+
// synthesize the conventional glyph text so the score still shows the marking.
|
|
1106
|
+
// Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
|
|
1107
|
+
// emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
|
|
1108
|
+
// synthesizing, and only when the engraver left them implicit.
|
|
1109
|
+
if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
|
|
1110
|
+
const s = direction.sound;
|
|
1111
|
+
const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
|
|
1112
|
+
: s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
|
|
1113
|
+
: s.tocoda ? 'To Coda'
|
|
1114
|
+
: s.fine ? 'Fine'
|
|
1115
|
+
: undefined;
|
|
1116
|
+
if (label) {
|
|
1117
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
1118
|
+
: direction.placement === 'below' ? Placement.below
|
|
1119
|
+
: undefined;
|
|
1120
|
+
marks.push({ markType: 'markup', content: label, placement });
|
|
1076
1121
|
}
|
|
1077
1122
|
}
|
|
1078
1123
|
|
|
@@ -1087,6 +1132,12 @@ interface MeasureConversionResult {
|
|
|
1087
1132
|
key?: KeySignature;
|
|
1088
1133
|
timeSig?: Fraction;
|
|
1089
1134
|
barline?: BarlineEvent;
|
|
1135
|
+
// A `location="left"` repeat (e.g. forward-repeat `.|:`) belongs at the FRONT
|
|
1136
|
+
// of this measure, but lilylet models a `\bar` as the END bar of its measure.
|
|
1137
|
+
// So it is carried back onto the PREVIOUS measure's trailing barline by
|
|
1138
|
+
// convertPart (dropped if this is the first measure — a leading `|:` at the
|
|
1139
|
+
// very start of the piece is conventionally omitted).
|
|
1140
|
+
leadingBarline?: BarlineEvent;
|
|
1090
1141
|
harmonies: HarmonyEvent[];
|
|
1091
1142
|
clefs: Map<number, ContextChange>; // staff number → clef context
|
|
1092
1143
|
}
|
|
@@ -1149,6 +1200,7 @@ const convertMeasure = (
|
|
|
1149
1200
|
let key: KeySignature | undefined;
|
|
1150
1201
|
let timeSig: Fraction | undefined;
|
|
1151
1202
|
let barline: BarlineEvent | undefined;
|
|
1203
|
+
let leadingBarline: BarlineEvent | undefined;
|
|
1152
1204
|
const harmonies: HarmonyEvent[] = [];
|
|
1153
1205
|
const clefs: Map<number, ContextChange> = new Map();
|
|
1154
1206
|
|
|
@@ -1166,6 +1218,15 @@ const convertMeasure = (
|
|
|
1166
1218
|
// layer (otherwise the encoder can't pair them and drops the span).
|
|
1167
1219
|
const pendingContextChanges: { ctx: ContextChange; staff?: number }[] = [];
|
|
1168
1220
|
let currentVoice = 1; // Track current voice for directions
|
|
1221
|
+
// Mid-measure clef support: a staff can change clef partway through a measure
|
|
1222
|
+
// (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
|
|
1223
|
+
// in an <attributes> block AFTER notes on that staff. We must attach it inline at
|
|
1224
|
+
// that point on the staff's currently-active voice, not collapse it into the
|
|
1225
|
+
// measure-start `clefs` map (which keeps only one clef per staff and emits it at
|
|
1226
|
+
// the bar start). Track the last voice that added a note on each staff, and which
|
|
1227
|
+
// staves already have notes this measure.
|
|
1228
|
+
const lastVoiceOnStaff: Map<number, number> = new Map();
|
|
1229
|
+
const staffHasNotes: Set<number> = new Set();
|
|
1169
1230
|
|
|
1170
1231
|
// Process all children in order
|
|
1171
1232
|
for (const child of getChildElements(measureEl)) {
|
|
@@ -1197,7 +1258,30 @@ const convertMeasure = (
|
|
|
1197
1258
|
for (const clefEntry of attrs.clefs) {
|
|
1198
1259
|
const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
|
|
1199
1260
|
if (clef) {
|
|
1200
|
-
|
|
1261
|
+
const staff = clefEntry.staff;
|
|
1262
|
+
if (staffHasNotes.has(staff)) {
|
|
1263
|
+
// Mid-measure clef change: this staff already has notes in this
|
|
1264
|
+
// measure, so the clef takes effect HERE, not at the bar start.
|
|
1265
|
+
// Append it inline on the staff's currently-active voice via
|
|
1266
|
+
// addEvent (which inserts a staff-switch context if that voice is
|
|
1267
|
+
// currently positioned on another staff — e.g. a cross-staff
|
|
1268
|
+
// voice — so the clef binds to `staff`, not the voice's current
|
|
1269
|
+
// staff). Zero duration: a clef does not advance time.
|
|
1270
|
+
const vNum = lastVoiceOnStaff.get(staff);
|
|
1271
|
+
if (vNum !== undefined) {
|
|
1272
|
+
// Tag the clef context with its staff explicitly. In a
|
|
1273
|
+
// cross-staff voice (where notes interleave staves) a bare
|
|
1274
|
+
// clef is ambiguous and is dropped/misplaced by the serializer;
|
|
1275
|
+
// the explicit staff makes it self-describing (serializer emits
|
|
1276
|
+
// `\staff "N" \clef ...`). addEvent still inserts a leading
|
|
1277
|
+
// staff switch when the voice is currently on another staff.
|
|
1278
|
+
voiceTracker.addEvent(vNum, { type: 'context', staff, clef } as ContextChange, 0, staff);
|
|
1279
|
+
} else {
|
|
1280
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1281
|
+
}
|
|
1282
|
+
} else {
|
|
1283
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1284
|
+
}
|
|
1201
1285
|
}
|
|
1202
1286
|
}
|
|
1203
1287
|
}
|
|
@@ -1206,6 +1290,10 @@ const convertMeasure = (
|
|
|
1206
1290
|
const voiceNum = note.voice;
|
|
1207
1291
|
const staffNum = note.staff || 1;
|
|
1208
1292
|
currentVoice = voiceNum;
|
|
1293
|
+
// Record that this staff now has notes this measure and which voice is
|
|
1294
|
+
// active on it, so a subsequent mid-measure <clef> attaches inline here.
|
|
1295
|
+
staffHasNotes.add(staffNum);
|
|
1296
|
+
lastVoiceOnStaff.set(staffNum, voiceNum);
|
|
1209
1297
|
|
|
1210
1298
|
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
1211
1299
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
@@ -1412,7 +1500,14 @@ const convertMeasure = (
|
|
|
1412
1500
|
const barlineData = parseBarline(child);
|
|
1413
1501
|
const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
|
|
1414
1502
|
if (style && style !== '|') {
|
|
1415
|
-
|
|
1503
|
+
// A `location="left"` barline (forward repeat at the measure front)
|
|
1504
|
+
// is carried back onto the previous measure's end bar by convertPart;
|
|
1505
|
+
// everything else is this measure's own trailing end bar.
|
|
1506
|
+
if (barlineData.location === 'left') {
|
|
1507
|
+
leadingBarline = { type: 'barline', style };
|
|
1508
|
+
} else {
|
|
1509
|
+
barline = { type: 'barline', style };
|
|
1510
|
+
}
|
|
1416
1511
|
}
|
|
1417
1512
|
} else if (tagName === 'harmony') {
|
|
1418
1513
|
const harmonyData = parseHarmony(child);
|
|
@@ -1506,7 +1601,7 @@ const convertMeasure = (
|
|
|
1506
1601
|
});
|
|
1507
1602
|
}
|
|
1508
1603
|
|
|
1509
|
-
return { voiceMap, key, timeSig, barline, harmonies, clefs };
|
|
1604
|
+
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
1510
1605
|
};
|
|
1511
1606
|
|
|
1512
1607
|
/**
|
|
@@ -1524,12 +1619,30 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1524
1619
|
let isFirstMeasure = true;
|
|
1525
1620
|
let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
|
|
1526
1621
|
const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
|
|
1622
|
+
// Initial clef per staff declared in the FIRST <attributes> block. A staff whose
|
|
1623
|
+
// first voice does not appear until a later measure (e.g. an accompaniment staff
|
|
1624
|
+
// silent for the opening bars) would otherwise lose its initial clef — it is only
|
|
1625
|
+
// declared in measure 1's attributes, where that staff has no voice to attach it
|
|
1626
|
+
// to. We hold these and flush each onto the staff's FIRST appearing voice.
|
|
1627
|
+
const pendingInitialClefs: Map<number, ContextChange> = new Map();
|
|
1527
1628
|
|
|
1528
1629
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
1529
1630
|
|
|
1530
1631
|
for (const measureEl of measureEls) {
|
|
1531
1632
|
voiceTracker.reset();
|
|
1532
|
-
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1633
|
+
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1634
|
+
|
|
1635
|
+
// A forward-repeat (or any `location="left"`) barline belongs at the END of
|
|
1636
|
+
// the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
|
|
1637
|
+
// Append it to the previous measure's first voice; if this is the first
|
|
1638
|
+
// measure there is no previous one, so a leading repeat at the very start of
|
|
1639
|
+
// the piece is conventionally dropped.
|
|
1640
|
+
if (leadingBarline && measures.length > 0) {
|
|
1641
|
+
const prevVoices = measures[measures.length - 1].parts[0]?.voices;
|
|
1642
|
+
if (prevVoices && prevVoices.length > 0) {
|
|
1643
|
+
prevVoices[0].events.push(leadingBarline);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1533
1646
|
|
|
1534
1647
|
// Update running key/time
|
|
1535
1648
|
if (key) lastKey = key;
|
|
@@ -1566,6 +1679,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1566
1679
|
lastClefs.set(voiceData.staff, clef);
|
|
1567
1680
|
}
|
|
1568
1681
|
}
|
|
1682
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1683
|
+
} else if (pendingInitialClefs.has(voiceData.staff)) {
|
|
1684
|
+
// This staff's clef was declared in an earlier measure where the staff
|
|
1685
|
+
// had no voice to carry it (its first appearance is delayed, or it is
|
|
1686
|
+
// an intermittently-empty cross-staff passage). This measure declares
|
|
1687
|
+
// no clef of its own — emit the carried-forward pending clef if it
|
|
1688
|
+
// differs from the clef last in effect for this staff.
|
|
1689
|
+
const pending = pendingInitialClefs.get(voiceData.staff)!;
|
|
1690
|
+
const lastClef = lastClefs.get(voiceData.staff);
|
|
1691
|
+
const isSameClef = lastClef &&
|
|
1692
|
+
(lastClef as ContextChange).clef === (pending as ContextChange).clef;
|
|
1693
|
+
if (!isSameClef) {
|
|
1694
|
+
events.push(pending);
|
|
1695
|
+
lastClefs.set(voiceData.staff, pending);
|
|
1696
|
+
}
|
|
1697
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1569
1698
|
}
|
|
1570
1699
|
staffsWithClef.add(voiceData.staff);
|
|
1571
1700
|
}
|
|
@@ -1573,6 +1702,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1573
1702
|
// Add voice events
|
|
1574
1703
|
events.push(...voiceData.events);
|
|
1575
1704
|
|
|
1705
|
+
// A mid-measure clef change was appended inline to this voice's events (it
|
|
1706
|
+
// does not pass through the measure-start `clefs` map). Scan for the LAST
|
|
1707
|
+
// clef per staff in this voice and update lastClefs, so the next measure's
|
|
1708
|
+
// dedup compares against the clef actually in effect — otherwise a later
|
|
1709
|
+
// measure restating the pre-change clef would be wrongly suppressed.
|
|
1710
|
+
{
|
|
1711
|
+
let scanStaff = voiceData.staff;
|
|
1712
|
+
for (const ev of voiceData.events) {
|
|
1713
|
+
if (ev.type === 'context') {
|
|
1714
|
+
const c = ev as ContextChange;
|
|
1715
|
+
if (c.staff != null) scanStaff = c.staff;
|
|
1716
|
+
if (c.clef) lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1576
1721
|
// Add harmonies and barline to first voice only
|
|
1577
1722
|
if (voiceNum === voiceNumbers[0]) {
|
|
1578
1723
|
for (const h of harmonies) {
|
|
@@ -1596,6 +1741,16 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1596
1741
|
lastVoiceStaff = voices[0].staff || 1;
|
|
1597
1742
|
}
|
|
1598
1743
|
|
|
1744
|
+
// Carry forward any clef declared this measure for a staff that had NO voice to
|
|
1745
|
+
// attach it to (an intermittently-empty staff — e.g. cross-staff passages where
|
|
1746
|
+
// the LH is written entirely on the upper staff for several bars). Remember it
|
|
1747
|
+
// as pending so it is emitted when that staff next reappears; otherwise the clef
|
|
1748
|
+
// (and any change it represents) is lost when this measure's `clefs` map is
|
|
1749
|
+
// discarded. Generalizes the first-measure seeding above to every measure.
|
|
1750
|
+
for (const [staff, clef] of clefs) {
|
|
1751
|
+
if (!staffsWithClef.has(staff)) pendingInitialClefs.set(staff, clef);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1599
1754
|
const measure: Measure = {
|
|
1600
1755
|
parts: [{
|
|
1601
1756
|
voices,
|
|
@@ -1730,6 +1885,14 @@ export const decode = (input: string | Uint8Array): LilyletDoc => {
|
|
|
1730
1885
|
measures: mergedMeasures,
|
|
1731
1886
|
};
|
|
1732
1887
|
|
|
1888
|
+
// Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
|
|
1889
|
+
// from the first part's barline + <sound> navigation markup → measure-layout
|
|
1890
|
+
// string. The MEI encoder turns this into an <expansion> that verovio unfolds
|
|
1891
|
+
// for MIDI. Repeat markup is part-global; the first part carries it. Never
|
|
1892
|
+
// throws (returns undefined when there is nothing to unfold).
|
|
1893
|
+
const measureLayout = measureLayoutFromPart(partEls[0]);
|
|
1894
|
+
if (measureLayout) metadata.measureLayout = measureLayout;
|
|
1895
|
+
|
|
1733
1896
|
if (Object.keys(metadata).length > 0) {
|
|
1734
1897
|
result.metadata = metadata;
|
|
1735
1898
|
}
|
|
@@ -124,6 +124,17 @@ export interface MusicXmlDirection {
|
|
|
124
124
|
octaveShift?: MusicXmlOctaveShift;
|
|
125
125
|
coda?: boolean;
|
|
126
126
|
segno?: boolean;
|
|
127
|
+
// Navigation jumps from the <sound> sibling inside <direction>. Present when
|
|
128
|
+
// the score carries D.C./D.S./Coda/Fine playback semantics; drives the
|
|
129
|
+
// performance-order (measure-layout) derivation, not a notational glyph.
|
|
130
|
+
sound?: {
|
|
131
|
+
dacapo?: boolean; // <sound dacapo="yes"> — da capo
|
|
132
|
+
dalsegno?: string; // <sound dalsegno="N"> — dal segno (target segno id)
|
|
133
|
+
segno?: string; // <sound segno="N"> — marks a segno point
|
|
134
|
+
coda?: string; // <sound coda="N"> — marks a coda point
|
|
135
|
+
tocoda?: string; // <sound tocoda="N"> — "to coda" jump
|
|
136
|
+
fine?: boolean; // <sound fine="yes"> — Fine (stop point on D.C./D.S.)
|
|
137
|
+
};
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
// ============ Barline Types ============
|
|
@@ -133,6 +144,7 @@ export interface MusicXmlBarline {
|
|
|
133
144
|
barStyle?: string; // regular, light-light, light-heavy, heavy-light, heavy-heavy, etc.
|
|
134
145
|
repeat?: {
|
|
135
146
|
direction: 'forward' | 'backward';
|
|
147
|
+
times?: number; // <repeat times="N"> — repeat count (default 2 for backward)
|
|
136
148
|
};
|
|
137
149
|
ending?: {
|
|
138
150
|
type: 'start' | 'stop' | 'discontinue';
|
|
@@ -36,6 +36,40 @@ import {
|
|
|
36
36
|
Placement,
|
|
37
37
|
Metadata,
|
|
38
38
|
} from "./types";
|
|
39
|
+
import { parseMeasureLayout, MeasureLayout } from "./measureLayout";
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// Walk a measure-layout AST, recording the FIRST measure index of each alternate
|
|
43
|
+
// ending (1st/2nd house) → its ending number, for the `% volta N` serializer
|
|
44
|
+
// comment convention. Only the alternates of a VoltaMLayout produce labels.
|
|
45
|
+
const collectVoltaLabels = (layout: MeasureLayout, out: Map<number, number>): void => {
|
|
46
|
+
const firstIndexOf = (node: MeasureLayout): number | undefined => {
|
|
47
|
+
switch (node.kind) {
|
|
48
|
+
case "single": return node.measure;
|
|
49
|
+
case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
|
|
50
|
+
case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
|
|
51
|
+
case "aba": return firstIndexOf(node.main);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const walk = (node: MeasureLayout): void => {
|
|
55
|
+
switch (node.kind) {
|
|
56
|
+
case "block": node.seq.forEach(walk); break;
|
|
57
|
+
case "aba": walk(node.main); node.rest.forEach(walk); break;
|
|
58
|
+
case "volta":
|
|
59
|
+
node.body.forEach(walk);
|
|
60
|
+
if (node.alternates) {
|
|
61
|
+
node.alternates.forEach((alt, ai) => {
|
|
62
|
+
const first = alt.length ? firstIndexOf(alt[0]) : undefined;
|
|
63
|
+
if (first !== undefined && !out.has(first)) out.set(first, ai + 1);
|
|
64
|
+
alt.forEach(walk);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
default: break;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(layout);
|
|
72
|
+
};
|
|
39
73
|
|
|
40
74
|
|
|
41
75
|
const PHONETS = "cdefgab";
|
|
@@ -483,9 +517,20 @@ const serializeTupletEvent = (
|
|
|
483
517
|
prevDuration = (e as RestEvent).duration;
|
|
484
518
|
} else if (e.type === 'context') {
|
|
485
519
|
const ctx = e as ContextChange;
|
|
520
|
+
// A context event inside a tuplet may carry several components at once
|
|
521
|
+
// (e.g. staff + clef). Emit each present one — previously only staff and
|
|
522
|
+
// stemDirection were handled, so a clef/ottava change inside a tuplet was
|
|
523
|
+
// silently dropped on serialization.
|
|
486
524
|
if (ctx.staff != null) {
|
|
487
525
|
parts.push(' \\staff "' + ctx.staff + '"');
|
|
488
|
-
}
|
|
526
|
+
}
|
|
527
|
+
if (ctx.clef != null) {
|
|
528
|
+
parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
|
|
529
|
+
}
|
|
530
|
+
if (ctx.ottava != null) {
|
|
531
|
+
parts.push(' \\ottava #' + ctx.ottava);
|
|
532
|
+
}
|
|
533
|
+
if (ctx.stemDirection != null) {
|
|
489
534
|
if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
|
|
490
535
|
else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
|
|
491
536
|
else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
|
|
@@ -586,7 +631,7 @@ const serializeEvent = (
|
|
|
586
631
|
case 'markup': {
|
|
587
632
|
const mk = event as MarkupEvent;
|
|
588
633
|
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
589
|
-
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
634
|
+
return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
|
|
590
635
|
}
|
|
591
636
|
case 'dynamic': {
|
|
592
637
|
const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
|
|
@@ -654,6 +699,28 @@ const serializeVoice = (
|
|
|
654
699
|
// context{staff} events before this index are skipped in the main loop.
|
|
655
700
|
let effectiveInitialStaff = voice.staff;
|
|
656
701
|
let leadStaffScanEnd = 0;
|
|
702
|
+
// Staff of the first MUSICAL event (note/rest/...), honouring an explicit
|
|
703
|
+
// note.staff. If it differs from the home staff, the first inline staff switch
|
|
704
|
+
// would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
|
|
705
|
+
// binds home to the first `\staff` before any music), relocating the voice and
|
|
706
|
+
// any leading clef. We then force an explicit home-staff anchor up front. This
|
|
707
|
+
// scan is independent of leadStaffScanEnd because a leading clef/key stops that
|
|
708
|
+
// scan before the first note is reached.
|
|
709
|
+
let firstMusicalStaff: number | null = null;
|
|
710
|
+
{
|
|
711
|
+
let scanStaff = voice.staff;
|
|
712
|
+
for (const e of voice.events) {
|
|
713
|
+
if (e.type === 'context') {
|
|
714
|
+
const ctx = e as ContextChange;
|
|
715
|
+
if (ctx.staff != null) scanStaff = ctx.staff;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (e.type === 'pitchReset') continue;
|
|
719
|
+
if (e.type === 'note') firstMusicalStaff = (e as NoteEvent).staff ?? scanStaff;
|
|
720
|
+
else firstMusicalStaff = scanStaff;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
657
724
|
for (let i = 0; i < voice.events.length; i++) {
|
|
658
725
|
const e = voice.events[i];
|
|
659
726
|
if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
|
|
@@ -678,10 +745,19 @@ const serializeVoice = (
|
|
|
678
745
|
break;
|
|
679
746
|
}
|
|
680
747
|
|
|
681
|
-
// Output staff command if voice staff
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
748
|
+
// Output staff command if the voice's initial staff is not what the PARSER will
|
|
749
|
+
// assume. The parser (lilylet.jison voice()) assigns every voice with no leading
|
|
750
|
+
// `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
|
|
751
|
+
// voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
|
|
752
|
+
// is not 1, even if the cross-measure carry (currentStaff) happens to match it
|
|
753
|
+
// (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
|
|
754
|
+
// this, the round-trip would silently relocate that voice, and its leading clef,
|
|
755
|
+
// to staff 1). Also emit when it differs from the carry (resets after grand staff)
|
|
756
|
+
// or always in a grand-staff part for clarity. Finally, force an anchor when the
|
|
757
|
+
// first note switches staff via note.staff (otherwise that inline `\staff` would
|
|
758
|
+
// be read as the leading staff and rebind the voice's home).
|
|
759
|
+
const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
|
|
760
|
+
if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
|
|
685
761
|
parts.push('\\staff "' + effectiveInitialStaff + '"');
|
|
686
762
|
}
|
|
687
763
|
|
|
@@ -710,7 +786,17 @@ const serializeVoice = (
|
|
|
710
786
|
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
711
787
|
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
712
788
|
// hoisted here — it is emitted inline at its position.
|
|
713
|
-
|
|
789
|
+
//
|
|
790
|
+
// The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
|
|
791
|
+
// the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
|
|
792
|
+
// with no clef of its own must only use the fallback to ESTABLISH a staff that has
|
|
793
|
+
// not been emitted yet — never to "restate" it once any sibling voice has already
|
|
794
|
+
// emitted/changed this staff's clef this measure, because the fallback would then be
|
|
795
|
+
// stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
|
|
796
|
+
// treble, then v2 with no clef must not re-emit the carried-in bass).
|
|
797
|
+
const ownClef = findVoiceClef(voice);
|
|
798
|
+
const carryClef = allStaffClefs?.[voice.staff];
|
|
799
|
+
const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
|
|
714
800
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
715
801
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
716
802
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
@@ -734,7 +820,8 @@ const serializeVoice = (
|
|
|
734
820
|
activeStaff = ctx.staff;
|
|
735
821
|
parts.push('\\staff "' + activeStaff + '"');
|
|
736
822
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
737
|
-
const ctxClef = ctx.clef ||
|
|
823
|
+
const ctxClef = ctx.clef ||
|
|
824
|
+
(emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
|
|
738
825
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
739
826
|
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
740
827
|
if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
|
|
@@ -763,7 +850,8 @@ const serializeVoice = (
|
|
|
763
850
|
activeStaff = effectiveStaff;
|
|
764
851
|
parts.push('\\staff "' + activeStaff + '"');
|
|
765
852
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
766
|
-
const targetClef =
|
|
853
|
+
const targetClef =
|
|
854
|
+
emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
|
|
767
855
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
768
856
|
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
769
857
|
if (emittedClefs) emittedClefs[activeStaff] = targetClef;
|
|
@@ -923,7 +1011,12 @@ const serializeMeasure = (
|
|
|
923
1011
|
|
|
924
1012
|
// Escape string for serialization (quotes and backslashes)
|
|
925
1013
|
const escapeString = (str: string): string => {
|
|
926
|
-
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');
|
|
927
1020
|
};
|
|
928
1021
|
|
|
929
1022
|
// Serialize metadata
|
|
@@ -954,6 +1047,9 @@ const serializeMetadata = (metadata: Metadata): string => {
|
|
|
954
1047
|
if (metadata.staves) {
|
|
955
1048
|
lines.push('[staves "' + escapeString(metadata.staves) + '"]');
|
|
956
1049
|
}
|
|
1050
|
+
if (metadata.measureLayout) {
|
|
1051
|
+
lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
|
|
1052
|
+
}
|
|
957
1053
|
if (metadata.instruments) {
|
|
958
1054
|
for (const [key, instr] of Object.entries(metadata.instruments)) {
|
|
959
1055
|
let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
|
|
@@ -1048,9 +1144,26 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
1048
1144
|
currentStaff = newStaff;
|
|
1049
1145
|
}
|
|
1050
1146
|
|
|
1051
|
-
//
|
|
1147
|
+
// Volta house-label comments (non-binding convention, like the `% n` measure
|
|
1148
|
+
// index): if a measure-layout directive is present, mark the first measure of
|
|
1149
|
+
// each alternate ending with a `% volta N` comment. Derived from the layout
|
|
1150
|
+
// AST; purely cosmetic, never parsed back.
|
|
1151
|
+
const voltaLabels = new Map<number, number>(); // 1-based measure index → ending number
|
|
1152
|
+
if (doc.metadata?.measureLayout) {
|
|
1153
|
+
try {
|
|
1154
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
1155
|
+
collectVoltaLabels(layout, voltaLabels);
|
|
1156
|
+
} catch { /* ignore malformed layout */ }
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Join measures with bar, measure number comment, and double newline. A
|
|
1160
|
+
// `% volta N` label (if any) is prepended on its own line before the measure.
|
|
1052
1161
|
const measuresOutput = measureStrs
|
|
1053
|
-
.map((m, i) =>
|
|
1162
|
+
.map((m, i) => {
|
|
1163
|
+
const label = voltaLabels.get(i + 1);
|
|
1164
|
+
const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
|
|
1165
|
+
return prefix + m + ' | %' + (i + 1);
|
|
1166
|
+
})
|
|
1054
1167
|
.join('\n\n');
|
|
1055
1168
|
parts.push(measuresOutput);
|
|
1056
1169
|
|
package/source/lilylet/types.ts
CHANGED
|
@@ -304,6 +304,10 @@ export interface Metadata {
|
|
|
304
304
|
instrument?: string;
|
|
305
305
|
genre?: string;
|
|
306
306
|
staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
|
|
307
|
+
// Raw measure-layout (performance/repeat order) DSL, lotus-style, e.g.
|
|
308
|
+
// "2*[1..8]{9,10}, 11..16" (index-wise) or "s: 4 <2 6> 2" (segment-wise).
|
|
309
|
+
// Stored verbatim; parsing/expansion to MEI <expansion> is a later phase.
|
|
310
|
+
measureLayout?: string;
|
|
307
311
|
// Per-staff / per-group instrument names, keyed by staff-layout group key (a single
|
|
308
312
|
// staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
|
|
309
313
|
// [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.
|