@k-l-lambda/lilylet 0.1.76 → 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 +120 -109
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +62 -7
- 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 +14 -0
- 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 +120 -109
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +10 -1
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +83 -7
- 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 +14 -0
- package/source/lilylet/types.ts +10 -1
|
@@ -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}"` : '';
|
|
@@ -2362,6 +2416,7 @@ const encode = (doc, options = {}) => {
|
|
|
2362
2416
|
const tieState = {};
|
|
2363
2417
|
// Track slur state across measures for cross-measure slurs
|
|
2364
2418
|
const slurState = {};
|
|
2419
|
+
const glissState = {};
|
|
2365
2420
|
// Track hairpin state across measures for cross-measure hairpins
|
|
2366
2421
|
const hairpinState = {};
|
|
2367
2422
|
// Track ottava state across measures for cross-measure ottava spans
|
|
@@ -2452,7 +2507,7 @@ const encode = (doc, options = {}) => {
|
|
|
2452
2507
|
if (openTag)
|
|
2453
2508
|
mei += openTag;
|
|
2454
2509
|
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2455
|
-
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2510
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
|
|
2456
2511
|
const closeTag = segCloseAt.get(mIndex);
|
|
2457
2512
|
if (closeTag)
|
|
2458
2513
|
mei += closeTag;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Improves upon musicxml2ly by properly tracking spanners (slurs, ties, wedges) by number attribute.
|
|
6
6
|
*/
|
|
7
7
|
import { DOMParser } from '@xmldom/xmldom';
|
|
8
|
-
import { HairpinType, NavigationMarkType, Placement, } from "./types.js";
|
|
8
|
+
import { HairpinType, NavigationMarkType, OrnamentType, Placement, } from "./types.js";
|
|
9
9
|
import { getElementText, getElementInt, getElements, getDirectChildren, getChildElements, getAttribute, getAttributeNumber, hasElement, convertPitch, convertDuration, convertKeySignature, convertClef, convertStemDirection, convertArticulation, convertOrnament, convertDynamic, convertPedal, convertBarlineStyle, convertHarmonyToText, createFraction, TYPE_TO_DIVISION, } from "./musicXmlUtils.js";
|
|
10
10
|
import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
|
|
11
11
|
// ============ Spanner Tracker ============
|
|
@@ -197,12 +197,13 @@ class VoiceTracker {
|
|
|
197
197
|
});
|
|
198
198
|
this.currentStaff.set(voiceNum, staff);
|
|
199
199
|
}
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
// NOTE: do NOT overwrite voice.staff on later notes. voice.staff is the voice's
|
|
201
|
+
// HOME staff — the staff of its first note — matching the parser, which fixes
|
|
202
|
+
// voice.staff to the first leading \staff (so `\staff "1" … \staff "2" …` gives
|
|
203
|
+
// voice.staff=1, with the switch carried by an inline ctx(staff=2) event). The
|
|
204
|
+
// previous code clobbered it to every note's staff, so a voice that ended on a
|
|
205
|
+
// different staff round-tripped with the wrong home staff.
|
|
206
|
+
return this.voices.get(voiceNum);
|
|
206
207
|
}
|
|
207
208
|
addEvent(voiceNum, event, duration, staff = 1) {
|
|
208
209
|
const voice = this.getOrCreateVoice(voiceNum, staff);
|
|
@@ -303,9 +304,11 @@ const parseNotations = (notationsEl) => {
|
|
|
303
304
|
result.ornaments = ornaments;
|
|
304
305
|
}
|
|
305
306
|
}
|
|
306
|
-
// Fermata
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
// Fermata. The "angled" shape is LilyPond's \shortfermata; everything else
|
|
308
|
+
// (normal/square/unset) is the ordinary \fermata.
|
|
309
|
+
const fermataEl = getElements(notationsEl, 'fermata')[0];
|
|
310
|
+
if (fermataEl) {
|
|
311
|
+
result.fermata = (fermataEl.textContent || '').trim() === 'angled' ? 'short' : 'normal';
|
|
309
312
|
}
|
|
310
313
|
// Arpeggiate
|
|
311
314
|
if (hasElement(notationsEl, 'arpeggiate')) {
|
|
@@ -326,6 +329,13 @@ const parseNotations = (notationsEl) => {
|
|
|
326
329
|
number: getAttributeNumber(tupletEl, 'number') || 1,
|
|
327
330
|
};
|
|
328
331
|
}
|
|
332
|
+
// Glissando / slide — both are note-to-note slide lines. Only the START note
|
|
333
|
+
// carries the mark (LilyPond \glissando auto-connects to the next note), so we
|
|
334
|
+
// record presence of any start; stops are ignored.
|
|
335
|
+
const slideEls = [...getElements(notationsEl, 'glissando'), ...getElements(notationsEl, 'slide')];
|
|
336
|
+
if (slideEls.some(el => getAttribute(el, 'type') === 'start')) {
|
|
337
|
+
result.glissando = true;
|
|
338
|
+
}
|
|
329
339
|
return result;
|
|
330
340
|
};
|
|
331
341
|
/**
|
|
@@ -570,6 +580,17 @@ const parseDirection = (dirEl) => {
|
|
|
570
580
|
if (hasElement(dirTypeEl, 'segno')) {
|
|
571
581
|
result.segno = true;
|
|
572
582
|
}
|
|
583
|
+
// Rehearsal mark (<direction-type><rehearsal>A</rehearsal>). May be split into
|
|
584
|
+
// multiple <rehearsal> (e.g. "A" + a Chinese gloss "段"); join them. Collected
|
|
585
|
+
// from the whole direction so it is found regardless of which direction-type it
|
|
586
|
+
// sits in. Surfaced as text → markup by directionToMarks (per the text-as-markup
|
|
587
|
+
// convention; LilyPond's musicxml2ly emits \mark \markup { \box { … } }).
|
|
588
|
+
const rehearsalEls = Array.from(dirEl.getElementsByTagName('rehearsal'));
|
|
589
|
+
if (rehearsalEls.length > 0) {
|
|
590
|
+
const text = rehearsalEls.map(el => el.textContent?.trim() || '').filter(Boolean).join(' ');
|
|
591
|
+
if (text)
|
|
592
|
+
result.rehearsal = text;
|
|
593
|
+
}
|
|
573
594
|
return result;
|
|
574
595
|
};
|
|
575
596
|
/**
|
|
@@ -640,6 +661,23 @@ const parseMetadata = (doc) => {
|
|
|
640
661
|
if (movementTitleEl?.textContent && !metadata.title) {
|
|
641
662
|
metadata.title = movementTitleEl.textContent.trim();
|
|
642
663
|
}
|
|
664
|
+
// Subtitle: MusicXML has no <subtitle> element — it lives in a page <credit>
|
|
665
|
+
// tagged <credit-type>subtitle</credit-type>, with the text in <credit-words>.
|
|
666
|
+
const creditEls = doc.getElementsByTagName('credit');
|
|
667
|
+
for (let i = 0; i < creditEls.length; i++) {
|
|
668
|
+
const credit = creditEls[i];
|
|
669
|
+
const typeEl = credit.getElementsByTagName('credit-type')[0];
|
|
670
|
+
if (typeEl?.textContent?.trim().toLowerCase() === 'subtitle') {
|
|
671
|
+
const words = Array.from(credit.getElementsByTagName('credit-words'))
|
|
672
|
+
.map(w => w.textContent?.trim() || '')
|
|
673
|
+
.filter(Boolean)
|
|
674
|
+
.join(' ');
|
|
675
|
+
if (words) {
|
|
676
|
+
metadata.subtitle = words;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
643
681
|
// Identification (composer, arranger, lyricist)
|
|
644
682
|
const identificationEl = doc.getElementsByTagName('identification')[0];
|
|
645
683
|
if (identificationEl) {
|
|
@@ -711,6 +749,12 @@ const notationsToMarks = (notations, spannerTracker, pitches) => {
|
|
|
711
749
|
// Articulations
|
|
712
750
|
if (notations.articulations) {
|
|
713
751
|
for (const artName of notations.articulations) {
|
|
752
|
+
// breath-mark is a NoDirection articulation in MusicXML but maps to
|
|
753
|
+
// LilyPond's \breathe — model it as the breath ornament.
|
|
754
|
+
if (artName === 'breath-mark') {
|
|
755
|
+
marks.push({ markType: 'ornament', type: OrnamentType.breath });
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
714
758
|
const artType = convertArticulation(artName);
|
|
715
759
|
if (artType) {
|
|
716
760
|
marks.push({ markType: 'articulation', type: artType });
|
|
@@ -728,12 +772,16 @@ const notationsToMarks = (notations, spannerTracker, pitches) => {
|
|
|
728
772
|
}
|
|
729
773
|
// Fermata
|
|
730
774
|
if (notations.fermata) {
|
|
731
|
-
marks.push({ markType: 'ornament', type: 'fermata' });
|
|
775
|
+
marks.push({ markType: 'ornament', type: (notations.fermata === 'short' ? 'shortFermata' : 'fermata') });
|
|
732
776
|
}
|
|
733
777
|
// Arpeggiate
|
|
734
778
|
if (notations.arpeggiate) {
|
|
735
779
|
marks.push({ markType: 'ornament', type: 'arpeggio' });
|
|
736
780
|
}
|
|
781
|
+
// Glissando / slide (start note only)
|
|
782
|
+
if (notations.glissando) {
|
|
783
|
+
marks.push({ markType: 'glissando' });
|
|
784
|
+
}
|
|
737
785
|
return marks;
|
|
738
786
|
};
|
|
739
787
|
// Common tempo words that should be converted to \tempo
|
|
@@ -887,16 +935,30 @@ const directionToMarks = (direction, spannerTracker) => {
|
|
|
887
935
|
if (direction.segno) {
|
|
888
936
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
889
937
|
}
|
|
938
|
+
// Rehearsal mark → markup (text-as-markup convention; LilyPond uses \mark).
|
|
939
|
+
if (direction.rehearsal) {
|
|
940
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
941
|
+
: direction.placement === 'below' ? Placement.below
|
|
942
|
+
: undefined;
|
|
943
|
+
marks.push({ markType: 'markup', content: direction.rehearsal, placement });
|
|
944
|
+
}
|
|
890
945
|
// Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
|
|
891
946
|
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
892
|
-
// ContextChange by directionToContextChange, so skip
|
|
893
|
-
// double-emitting;
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
947
|
+
// ContextChange by directionToContextChange (which takes words[0]), so skip that
|
|
948
|
+
// one here to avoid double-emitting; the REST become a markup mark → MEI <dir>.
|
|
949
|
+
// A single <direction> often carries a tempo word AND an expressive/translation
|
|
950
|
+
// word together (e.g. <words>Moderato</words><words>中板</words>) — only the tempo
|
|
951
|
+
// word is consumed; the rest must still surface as markup. Metronome directions
|
|
952
|
+
// are tempo too, never markup. Navigation text ("D.C. al Fine", "To Coda",
|
|
953
|
+
// "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a markup glyph
|
|
954
|
+
// — the jump SEMANTICS are captured separately as measure-layout.
|
|
897
955
|
let emittedWords = false;
|
|
898
956
|
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
899
|
-
|
|
957
|
+
// If words[0] was consumed as the tempo text, drop it and keep the remainder;
|
|
958
|
+
// otherwise the whole run is one expressive phrase, joined as today.
|
|
959
|
+
const firstIsTempo = isTempoWord(direction.words[0].text.trim());
|
|
960
|
+
const rest = firstIsTempo ? direction.words.slice(1) : direction.words;
|
|
961
|
+
const text = rest.map(w => w.text).join('').trim();
|
|
900
962
|
if (text && !isTempoWord(text)) {
|
|
901
963
|
const placement = direction.placement === 'above' ? Placement.above
|
|
902
964
|
: direction.placement === 'below' ? Placement.below
|
|
@@ -1107,8 +1169,16 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1107
1169
|
// hairpin stop that lands just before a rest (common in piano scores:
|
|
1108
1170
|
// `<pedal stop/>` followed by rests filling the voice) is silently dropped.
|
|
1109
1171
|
const marks = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
|
|
1110
|
-
if (!note.isRest)
|
|
1172
|
+
if (!note.isRest) {
|
|
1111
1173
|
pendingMarks.delete(voiceNum);
|
|
1174
|
+
// Drain directions that named this note's staff but had no known voice
|
|
1175
|
+
// yet (queued under the -staff placeholder key by the <direction> handler).
|
|
1176
|
+
const staffPending = pendingMarks.get(-staffNum);
|
|
1177
|
+
if (staffPending && staffPending.length > 0) {
|
|
1178
|
+
marks.push(...staffPending);
|
|
1179
|
+
pendingMarks.delete(-staffNum);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1112
1182
|
if (note.isRest) {
|
|
1113
1183
|
// Rest event
|
|
1114
1184
|
const duration = convertDuration(voiceTracker.getDivisions(), note.duration.divisions, note.duration.type, note.duration.dots, note.duration.timeModification);
|
|
@@ -1126,7 +1196,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1126
1196
|
// so the encoder can emit <fermata startid="#rest">; without this the
|
|
1127
1197
|
// 3-of-4 fermatas that sit on rests in typical piano scores are lost.
|
|
1128
1198
|
if (note.notations?.fermata) {
|
|
1129
|
-
restEvent.marks = [{ markType: 'ornament', type: 'fermata' }];
|
|
1199
|
+
restEvent.marks = [{ markType: 'ornament', type: (note.notations.fermata === 'short' ? 'shortFermata' : 'fermata') }];
|
|
1130
1200
|
}
|
|
1131
1201
|
// Grace notes don't advance time
|
|
1132
1202
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
@@ -1225,9 +1295,17 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1225
1295
|
// Handle marks (dynamics, hairpins, etc.)
|
|
1226
1296
|
const marks = directionToMarks(direction, spannerTracker);
|
|
1227
1297
|
if (marks.length > 0) {
|
|
1228
|
-
//
|
|
1229
|
-
|
|
1230
|
-
|
|
1298
|
+
// Route to the voice the marks belong to. A <direction> in a grand-staff
|
|
1299
|
+
// part names its <staff>; the matching note is the NEXT note on that staff
|
|
1300
|
+
// — NOT currentVoice, which after a <backup> still points at the voice that
|
|
1301
|
+
// just ended (so a staff-2 direction emitted after the backup would wrongly
|
|
1302
|
+
// land on staff 1). Queue under a -staff placeholder key; the next note on
|
|
1303
|
+
// that staff drains it, and the end-of-measure flush falls back to that
|
|
1304
|
+
// staff's last note if no further note appears. Without a staff (single-
|
|
1305
|
+
// staff parts), fall back to currentVoice as before.
|
|
1306
|
+
const targetKey = direction.staff ? -direction.staff : currentVoice;
|
|
1307
|
+
const existing = pendingMarks.get(targetKey) || [];
|
|
1308
|
+
pendingMarks.set(targetKey, [...existing, ...marks]);
|
|
1231
1309
|
}
|
|
1232
1310
|
}
|
|
1233
1311
|
else if (tagName === 'backup') {
|
|
@@ -1303,11 +1381,28 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1303
1381
|
}
|
|
1304
1382
|
return undefined;
|
|
1305
1383
|
};
|
|
1384
|
+
// A staff-keyed leftover (negative key = -staffNum, queued for a "next note on
|
|
1385
|
+
// this staff" that never came): resolve to the last note on any voice that lives
|
|
1386
|
+
// on that staff.
|
|
1387
|
+
const findLastNoteOnStaff = (staff) => {
|
|
1388
|
+
let best;
|
|
1389
|
+
for (const [, vs] of allVoices) {
|
|
1390
|
+
if ((vs.staff || 1) !== staff)
|
|
1391
|
+
continue;
|
|
1392
|
+
for (let i = vs.events.length - 1; i >= 0; i--) {
|
|
1393
|
+
if (vs.events[i].type === 'note') {
|
|
1394
|
+
best = vs.events[i];
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return best;
|
|
1400
|
+
};
|
|
1306
1401
|
for (const [voiceNum, marks] of pendingMarks) {
|
|
1307
1402
|
if (marks.length === 0)
|
|
1308
1403
|
continue;
|
|
1309
|
-
let target = findLastNote(voiceNum);
|
|
1310
|
-
// Voice had no note (e.g. direction-only or rest-only): attach to the
|
|
1404
|
+
let target = voiceNum < 0 ? findLastNoteOnStaff(-voiceNum) : findLastNote(voiceNum);
|
|
1405
|
+
// Voice/staff had no note (e.g. direction-only or rest-only): attach to the
|
|
1311
1406
|
// last note of any voice so the marking is not silently dropped.
|
|
1312
1407
|
if (!target) {
|
|
1313
1408
|
for (const vn of allVoices.keys()) {
|