@k-l-lambda/lilylet 0.1.83 → 0.1.85
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/musicXmlDecoder.js +59 -2
- package/lib/lilylet/musicXmlEncoder.js +30 -4
- package/lib/lilylet/musicXmlTypes.d.ts +8 -0
- package/package.json +1 -1
- package/source/lilylet/musicXmlDecoder.ts +61 -3
- package/source/lilylet/musicXmlEncoder.ts +44 -4
- package/source/lilylet/musicXmlTypes.ts +10 -0
|
@@ -408,6 +408,23 @@ const parseNote = (noteEl, divisions) => {
|
|
|
408
408
|
number: getAttributeNumber(el, 'number') || 1,
|
|
409
409
|
}));
|
|
410
410
|
}
|
|
411
|
+
// Lyrics are direct children of <note>; retain one slot per verse.
|
|
412
|
+
const lyricEls = getDirectChildren(noteEl, 'lyric');
|
|
413
|
+
let lyrics;
|
|
414
|
+
if (lyricEls.length > 0) {
|
|
415
|
+
lyrics = lyricEls.map(lyricEl => {
|
|
416
|
+
const rawSyllabic = getElementText(lyricEl, 'syllabic');
|
|
417
|
+
const syllabic = rawSyllabic === 'begin' || rawSyllabic === 'middle' ||
|
|
418
|
+
rawSyllabic === 'end' || rawSyllabic === 'single' ? rawSyllabic : 'single';
|
|
419
|
+
const textEl = getDirectChildren(lyricEl, 'text')[0];
|
|
420
|
+
return {
|
|
421
|
+
number: getAttributeNumber(lyricEl, 'number') || 1,
|
|
422
|
+
text: textEl?.textContent ?? undefined,
|
|
423
|
+
syllabic,
|
|
424
|
+
extend: getDirectChildren(lyricEl, 'extend').length > 0,
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
411
428
|
return {
|
|
412
429
|
isChord,
|
|
413
430
|
isRest,
|
|
@@ -426,6 +443,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
426
443
|
notations,
|
|
427
444
|
fingerings,
|
|
428
445
|
beams,
|
|
446
|
+
lyrics,
|
|
429
447
|
};
|
|
430
448
|
};
|
|
431
449
|
/**
|
|
@@ -1043,6 +1061,39 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1043
1061
|
let leadingBarline;
|
|
1044
1062
|
const harmonies = [];
|
|
1045
1063
|
const clefs = new Map();
|
|
1064
|
+
const lyricLanes = new Map();
|
|
1065
|
+
const lyricForNote = (note, voiceNum, staffNum) => {
|
|
1066
|
+
if (note.isGrace || note.isChord || note.isRest)
|
|
1067
|
+
return;
|
|
1068
|
+
let state = lyricLanes.get(voiceNum);
|
|
1069
|
+
if (!state) {
|
|
1070
|
+
state = { staff: staffNum, lanes: new Map(), noteIndex: 0 };
|
|
1071
|
+
lyricLanes.set(voiceNum, state);
|
|
1072
|
+
}
|
|
1073
|
+
const byVerse = new Map((note.lyrics || []).map(l => [l.number, l]));
|
|
1074
|
+
for (const number of byVerse.keys()) {
|
|
1075
|
+
if (!state.lanes.has(number)) {
|
|
1076
|
+
const lane = [];
|
|
1077
|
+
for (let i = 0; i < state.noteIndex; i++)
|
|
1078
|
+
lane.push({ skip: true });
|
|
1079
|
+
state.lanes.set(number, lane);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
for (const [number, lane] of state.lanes) {
|
|
1083
|
+
const lyric = byVerse.get(number);
|
|
1084
|
+
if (!lyric) {
|
|
1085
|
+
lane.push({ skip: true });
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
lane.push({
|
|
1089
|
+
text: lyric.text,
|
|
1090
|
+
hyphen: lyric.syllabic === 'begin' || lyric.syllabic === 'middle' ? true : undefined,
|
|
1091
|
+
extend: lyric.extend || undefined,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
state.noteIndex++;
|
|
1096
|
+
};
|
|
1046
1097
|
// Pending marks from directions (to attach to next note), per voice
|
|
1047
1098
|
const pendingMarks = new Map();
|
|
1048
1099
|
// Accumulated <forward> ticks waiting for the next note, whose <voice> tells us
|
|
@@ -1124,6 +1175,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1124
1175
|
const note = parseNote(child, voiceTracker.getDivisions());
|
|
1125
1176
|
const voiceNum = note.voice;
|
|
1126
1177
|
const staffNum = note.staff || 1;
|
|
1178
|
+
lyricForNote(note, voiceNum, staffNum);
|
|
1127
1179
|
currentVoice = voiceNum;
|
|
1128
1180
|
// Record that this staff now has notes this measure and which voice is
|
|
1129
1181
|
// active on it, so a subsequent mid-measure <clef> attaches inline here.
|
|
@@ -1431,8 +1483,9 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1431
1483
|
}
|
|
1432
1484
|
if (voiceNum === undefined)
|
|
1433
1485
|
voiceNum = voices.keys().next().value;
|
|
1434
|
-
if (voiceNum
|
|
1435
|
-
|
|
1486
|
+
if (voiceNum === undefined)
|
|
1487
|
+
voiceNum = 1;
|
|
1488
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, pc.staff ?? 1);
|
|
1436
1489
|
}
|
|
1437
1490
|
pendingContextChanges.length = 0;
|
|
1438
1491
|
}
|
|
@@ -1442,6 +1495,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1442
1495
|
voiceMap.set(voiceNum, {
|
|
1443
1496
|
events: voiceState.events,
|
|
1444
1497
|
staff: voiceState.staff,
|
|
1498
|
+
lyrics: lyricLanes.get(voiceNum)?.lanes || new Map(),
|
|
1445
1499
|
});
|
|
1446
1500
|
}
|
|
1447
1501
|
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
@@ -1566,6 +1620,9 @@ const convertPart = (partEl) => {
|
|
|
1566
1620
|
voices.push({
|
|
1567
1621
|
staff: voiceData.staff,
|
|
1568
1622
|
events,
|
|
1623
|
+
lyrics: voiceData.lyrics.size > 0
|
|
1624
|
+
? Array.from(voiceData.lyrics.entries()).sort(([a], [b]) => a - b).map(([verse, syllables]) => ({ verse, syllables }))
|
|
1625
|
+
: undefined,
|
|
1569
1626
|
});
|
|
1570
1627
|
}
|
|
1571
1628
|
// If no voices found, create an empty one
|
|
@@ -103,6 +103,30 @@ const escapeXml = (text) => {
|
|
|
103
103
|
.replace(/'/g, ''');
|
|
104
104
|
};
|
|
105
105
|
const indent = (level) => ' '.repeat(level);
|
|
106
|
+
const makeLyricCursors = (lanes) => (lanes || []).map(lane => ({ lane, index: 0, inChain: false }));
|
|
107
|
+
const lyricForCursor = (cursor) => {
|
|
108
|
+
const syllable = cursor.lane.syllables[cursor.index++];
|
|
109
|
+
if (!syllable || syllable.skip || syllable.text === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const syllabic = cursor.inChain
|
|
112
|
+
? (syllable.hyphen ? 'middle' : 'end')
|
|
113
|
+
: (syllable.hyphen ? 'begin' : 'single');
|
|
114
|
+
cursor.inChain = !!syllable.hyphen;
|
|
115
|
+
return `<lyric number="${cursor.lane.verse || 1}"><syllabic>${syllabic}</syllabic>` +
|
|
116
|
+
`<text>${escapeXml(syllable.text)}</text>${syllable.extend ? '<extend/>' : ''}</lyric>`;
|
|
117
|
+
};
|
|
118
|
+
const lyricXmlForNote = (cursors, level, grace) => {
|
|
119
|
+
if (grace)
|
|
120
|
+
return '';
|
|
121
|
+
let xml = '';
|
|
122
|
+
for (const cursor of cursors) {
|
|
123
|
+
const lyric = lyricForCursor(cursor);
|
|
124
|
+
if (lyric) {
|
|
125
|
+
xml += `${indent(level)}${lyric}\n`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return xml;
|
|
129
|
+
};
|
|
106
130
|
// === Encoding Functions ===
|
|
107
131
|
/**
|
|
108
132
|
* Encode pitch to MusicXML
|
|
@@ -289,7 +313,7 @@ const encodeNotations = (marks, level) => {
|
|
|
289
313
|
/**
|
|
290
314
|
* Encode a note event
|
|
291
315
|
*/
|
|
292
|
-
const encodeNote = (event, voice, staff, level, isChord = false) => {
|
|
316
|
+
const encodeNote = (event, voice, staff, level, isChord = false, lyricXml = '') => {
|
|
293
317
|
let xml = `${indent(level)}<note>\n`;
|
|
294
318
|
if (isChord) {
|
|
295
319
|
xml += `${indent(level + 1)}<chord/>\n`;
|
|
@@ -341,6 +365,7 @@ const encodeNote = (event, voice, staff, level, isChord = false) => {
|
|
|
341
365
|
if (event.marks && event.marks.length > 0) {
|
|
342
366
|
xml += encodeNotations(event.marks, level + 1);
|
|
343
367
|
}
|
|
368
|
+
xml += lyricXml;
|
|
344
369
|
xml += `${indent(level)}</note>\n`;
|
|
345
370
|
return xml;
|
|
346
371
|
};
|
|
@@ -579,6 +604,7 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
579
604
|
for (const voice of part.voices) {
|
|
580
605
|
let currentStaff = voice.staff || 1;
|
|
581
606
|
let voicePosition = 0;
|
|
607
|
+
const lyricCursors = makeLyricCursors(voice.lyrics);
|
|
582
608
|
// Backup if needed
|
|
583
609
|
if (currentPosition > 0 && voiceNum > 1) {
|
|
584
610
|
xml += `${indent(level + 1)}<backup>\n`;
|
|
@@ -597,7 +623,7 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
597
623
|
xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
|
|
598
624
|
}
|
|
599
625
|
// Encode main note
|
|
600
|
-
xml += encodeNote(event, voiceNum, currentStaff, level + 1);
|
|
626
|
+
xml += encodeNote(event, voiceNum, currentStaff, level + 1, false, lyricXmlForNote(lyricCursors, level + 2, event.grace));
|
|
601
627
|
const dur = calculateDuration(event.duration);
|
|
602
628
|
voicePosition += dur;
|
|
603
629
|
// Encode chord notes. Marks live on the parent NoteEvent (the whole
|
|
@@ -658,11 +684,11 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
658
684
|
if (tupletMarks.length > 0) {
|
|
659
685
|
const origMarks = subEvent.marks;
|
|
660
686
|
subEvent.marks = [...(subEvent.marks || []), ...tupletMarks];
|
|
661
|
-
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
|
|
687
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1, false, lyricXmlForNote(lyricCursors, level + 2, subEvent.grace));
|
|
662
688
|
subEvent.marks = origMarks;
|
|
663
689
|
}
|
|
664
690
|
else {
|
|
665
|
-
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
|
|
691
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1, false, lyricXmlForNote(lyricCursors, level + 2, subEvent.grace));
|
|
666
692
|
}
|
|
667
693
|
const dur = calculateDuration(subEvent.duration);
|
|
668
694
|
voicePosition += dur;
|
|
@@ -42,6 +42,13 @@ export interface MusicXmlAttributes {
|
|
|
42
42
|
staves?: number;
|
|
43
43
|
}
|
|
44
44
|
export type MusicXmlStemDirection = 'up' | 'down';
|
|
45
|
+
export type MusicXmlLyricSyllabic = 'single' | 'begin' | 'middle' | 'end';
|
|
46
|
+
export interface MusicXmlLyric {
|
|
47
|
+
number: number;
|
|
48
|
+
text?: string;
|
|
49
|
+
syllabic?: MusicXmlLyricSyllabic;
|
|
50
|
+
extend?: boolean;
|
|
51
|
+
}
|
|
45
52
|
export interface MusicXmlNotations {
|
|
46
53
|
ties?: {
|
|
47
54
|
type: 'start' | 'stop';
|
|
@@ -84,6 +91,7 @@ export interface MusicXmlNote {
|
|
|
84
91
|
type: 'begin' | 'continue' | 'end';
|
|
85
92
|
number: number;
|
|
86
93
|
}[];
|
|
94
|
+
lyrics?: MusicXmlLyric[];
|
|
87
95
|
}
|
|
88
96
|
export interface MusicXmlDynamic {
|
|
89
97
|
type: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
BarlineEvent,
|
|
31
31
|
HarmonyEvent,
|
|
32
32
|
TupletEvent,
|
|
33
|
+
LyricLane,
|
|
34
|
+
LyricSyllable,
|
|
33
35
|
} from './types';
|
|
34
36
|
|
|
35
37
|
import {
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
MusicXmlHarmony,
|
|
45
47
|
MusicXmlNotations,
|
|
46
48
|
MusicXmlPitch,
|
|
49
|
+
MusicXmlLyric,
|
|
47
50
|
} from './musicXmlTypes';
|
|
48
51
|
|
|
49
52
|
import {
|
|
@@ -554,6 +557,24 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
554
557
|
}));
|
|
555
558
|
}
|
|
556
559
|
|
|
560
|
+
// Lyrics are direct children of <note>; retain one slot per verse.
|
|
561
|
+
const lyricEls = getDirectChildren(noteEl, 'lyric');
|
|
562
|
+
let lyrics: MusicXmlLyric[] | undefined;
|
|
563
|
+
if (lyricEls.length > 0) {
|
|
564
|
+
lyrics = lyricEls.map(lyricEl => {
|
|
565
|
+
const rawSyllabic = getElementText(lyricEl, 'syllabic');
|
|
566
|
+
const syllabic = rawSyllabic === 'begin' || rawSyllabic === 'middle' ||
|
|
567
|
+
rawSyllabic === 'end' || rawSyllabic === 'single' ? rawSyllabic : 'single';
|
|
568
|
+
const textEl = getDirectChildren(lyricEl, 'text')[0];
|
|
569
|
+
return {
|
|
570
|
+
number: getAttributeNumber(lyricEl, 'number') || 1,
|
|
571
|
+
text: textEl?.textContent ?? undefined,
|
|
572
|
+
syllabic,
|
|
573
|
+
extend: getDirectChildren(lyricEl, 'extend').length > 0,
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
557
578
|
return {
|
|
558
579
|
isChord,
|
|
559
580
|
isRest,
|
|
@@ -572,6 +593,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
572
593
|
notations,
|
|
573
594
|
fingerings,
|
|
574
595
|
beams,
|
|
596
|
+
lyrics,
|
|
575
597
|
};
|
|
576
598
|
};
|
|
577
599
|
|
|
@@ -1192,7 +1214,7 @@ const directionToMarks = (
|
|
|
1192
1214
|
* Result of converting a measure - now includes voices grouped by voice number
|
|
1193
1215
|
*/
|
|
1194
1216
|
interface MeasureConversionResult {
|
|
1195
|
-
voiceMap: Map<number, { events: Event[]; staff: number }>;
|
|
1217
|
+
voiceMap: Map<number, { events: Event[]; staff: number; lyrics: Map<number, LyricSyllable[]> }>;
|
|
1196
1218
|
key?: KeySignature;
|
|
1197
1219
|
timeSig?: Fraction;
|
|
1198
1220
|
barline?: BarlineEvent;
|
|
@@ -1267,6 +1289,36 @@ const convertMeasure = (
|
|
|
1267
1289
|
let leadingBarline: BarlineEvent | undefined;
|
|
1268
1290
|
const harmonies: HarmonyEvent[] = [];
|
|
1269
1291
|
const clefs: Map<number, ContextChange> = new Map();
|
|
1292
|
+
const lyricLanes: Map<number, { staff: number; lanes: Map<number, LyricSyllable[]>; noteIndex: number }> = new Map();
|
|
1293
|
+
const lyricForNote = (note: MusicXmlNote, voiceNum: number, staffNum: number): void => {
|
|
1294
|
+
if (note.isGrace || note.isChord || note.isRest) return;
|
|
1295
|
+
let state = lyricLanes.get(voiceNum);
|
|
1296
|
+
if (!state) {
|
|
1297
|
+
state = { staff: staffNum, lanes: new Map(), noteIndex: 0 };
|
|
1298
|
+
lyricLanes.set(voiceNum, state);
|
|
1299
|
+
}
|
|
1300
|
+
const byVerse = new Map((note.lyrics || []).map(l => [l.number, l]));
|
|
1301
|
+
for (const number of byVerse.keys()) {
|
|
1302
|
+
if (!state.lanes.has(number)) {
|
|
1303
|
+
const lane: LyricSyllable[] = [];
|
|
1304
|
+
for (let i = 0; i < state.noteIndex; i++) lane.push({ skip: true });
|
|
1305
|
+
state.lanes.set(number, lane);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
for (const [number, lane] of state.lanes) {
|
|
1309
|
+
const lyric = byVerse.get(number);
|
|
1310
|
+
if (!lyric) {
|
|
1311
|
+
lane.push({ skip: true });
|
|
1312
|
+
} else {
|
|
1313
|
+
lane.push({
|
|
1314
|
+
text: lyric.text,
|
|
1315
|
+
hyphen: lyric.syllabic === 'begin' || lyric.syllabic === 'middle' ? true : undefined,
|
|
1316
|
+
extend: lyric.extend || undefined,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
state.noteIndex++;
|
|
1321
|
+
};
|
|
1270
1322
|
|
|
1271
1323
|
// Pending marks from directions (to attach to next note), per voice
|
|
1272
1324
|
const pendingMarks: Map<number, Mark[]> = new Map();
|
|
@@ -1353,6 +1405,7 @@ const convertMeasure = (
|
|
|
1353
1405
|
const note = parseNote(child, voiceTracker.getDivisions());
|
|
1354
1406
|
const voiceNum = note.voice;
|
|
1355
1407
|
const staffNum = note.staff || 1;
|
|
1408
|
+
lyricForNote(note, voiceNum, staffNum);
|
|
1356
1409
|
currentVoice = voiceNum;
|
|
1357
1410
|
// Record that this staff now has notes this measure and which voice is
|
|
1358
1411
|
// active on it, so a subsequent mid-measure <clef> attaches inline here.
|
|
@@ -1681,17 +1734,19 @@ const convertMeasure = (
|
|
|
1681
1734
|
if (vs.staff === pc.staff) { voiceNum = vn; break; }
|
|
1682
1735
|
}
|
|
1683
1736
|
if (voiceNum === undefined) voiceNum = voices.keys().next().value;
|
|
1684
|
-
if (voiceNum
|
|
1737
|
+
if (voiceNum === undefined) voiceNum = 1;
|
|
1738
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, pc.staff ?? 1);
|
|
1685
1739
|
}
|
|
1686
1740
|
pendingContextChanges.length = 0;
|
|
1687
1741
|
}
|
|
1688
1742
|
|
|
1689
1743
|
// Build voice map from tracker
|
|
1690
|
-
const voiceMap = new Map<number, { events: Event[]; staff: number }>();
|
|
1744
|
+
const voiceMap = new Map<number, { events: Event[]; staff: number; lyrics: Map<number, LyricSyllable[]> }>();
|
|
1691
1745
|
for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
|
|
1692
1746
|
voiceMap.set(voiceNum, {
|
|
1693
1747
|
events: voiceState.events,
|
|
1694
1748
|
staff: voiceState.staff,
|
|
1749
|
+
lyrics: lyricLanes.get(voiceNum)?.lanes || new Map(),
|
|
1695
1750
|
});
|
|
1696
1751
|
}
|
|
1697
1752
|
|
|
@@ -1825,6 +1880,9 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1825
1880
|
voices.push({
|
|
1826
1881
|
staff: voiceData.staff,
|
|
1827
1882
|
events,
|
|
1883
|
+
lyrics: voiceData.lyrics.size > 0
|
|
1884
|
+
? Array.from(voiceData.lyrics.entries()).sort(([a], [b]) => a - b).map(([verse, syllables]) => ({ verse, syllables }))
|
|
1885
|
+
: undefined,
|
|
1828
1886
|
});
|
|
1829
1887
|
}
|
|
1830
1888
|
|
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
Tempo,
|
|
37
37
|
Metadata,
|
|
38
38
|
Fraction,
|
|
39
|
+
LyricLane,
|
|
40
|
+
LyricSyllable,
|
|
39
41
|
} from "./types";
|
|
40
42
|
|
|
41
43
|
import {
|
|
@@ -157,6 +159,38 @@ const escapeXml = (text: string): string => {
|
|
|
157
159
|
|
|
158
160
|
const indent = (level: number): string => ' '.repeat(level);
|
|
159
161
|
|
|
162
|
+
interface MusicXmlLyricCursor {
|
|
163
|
+
lane: LyricLane;
|
|
164
|
+
index: number;
|
|
165
|
+
inChain: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const makeLyricCursors = (lanes: LyricLane[] | undefined): MusicXmlLyricCursor[] =>
|
|
169
|
+
(lanes || []).map(lane => ({ lane, index: 0, inChain: false }));
|
|
170
|
+
|
|
171
|
+
const lyricForCursor = (cursor: MusicXmlLyricCursor): string | undefined => {
|
|
172
|
+
const syllable = cursor.lane.syllables[cursor.index++];
|
|
173
|
+
if (!syllable || syllable.skip || syllable.text === undefined) return undefined;
|
|
174
|
+
const syllabic = cursor.inChain
|
|
175
|
+
? (syllable.hyphen ? 'middle' : 'end')
|
|
176
|
+
: (syllable.hyphen ? 'begin' : 'single');
|
|
177
|
+
cursor.inChain = !!syllable.hyphen;
|
|
178
|
+
return `<lyric number="${cursor.lane.verse || 1}"><syllabic>${syllabic}</syllabic>` +
|
|
179
|
+
`<text>${escapeXml(syllable.text)}</text>${syllable.extend ? '<extend/>' : ''}</lyric>`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const lyricXmlForNote = (cursors: MusicXmlLyricCursor[], level: number, grace?: boolean): string => {
|
|
183
|
+
if (grace) return '';
|
|
184
|
+
let xml = '';
|
|
185
|
+
for (const cursor of cursors) {
|
|
186
|
+
const lyric = lyricForCursor(cursor);
|
|
187
|
+
if (lyric) {
|
|
188
|
+
xml += `${indent(level)}${lyric}\n`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return xml;
|
|
192
|
+
};
|
|
193
|
+
|
|
160
194
|
|
|
161
195
|
// === Encoding Functions ===
|
|
162
196
|
|
|
@@ -396,7 +430,8 @@ const encodeNote = (
|
|
|
396
430
|
voice: number,
|
|
397
431
|
staff: number,
|
|
398
432
|
level: number,
|
|
399
|
-
isChord: boolean = false
|
|
433
|
+
isChord: boolean = false,
|
|
434
|
+
lyricXml: string = ''
|
|
400
435
|
): string => {
|
|
401
436
|
let xml = `${indent(level)}<note>\n`;
|
|
402
437
|
|
|
@@ -458,6 +493,7 @@ const encodeNote = (
|
|
|
458
493
|
xml += encodeNotations(event.marks, level + 1);
|
|
459
494
|
}
|
|
460
495
|
|
|
496
|
+
xml += lyricXml;
|
|
461
497
|
xml += `${indent(level)}</note>\n`;
|
|
462
498
|
|
|
463
499
|
return xml;
|
|
@@ -762,6 +798,7 @@ const encodeMeasure = (
|
|
|
762
798
|
for (const voice of part.voices) {
|
|
763
799
|
let currentStaff = voice.staff || 1;
|
|
764
800
|
let voicePosition = 0;
|
|
801
|
+
const lyricCursors = makeLyricCursors(voice.lyrics);
|
|
765
802
|
|
|
766
803
|
// Backup if needed
|
|
767
804
|
if (currentPosition > 0 && voiceNum > 1) {
|
|
@@ -785,7 +822,8 @@ const encodeMeasure = (
|
|
|
785
822
|
}
|
|
786
823
|
|
|
787
824
|
// Encode main note
|
|
788
|
-
xml += encodeNote(event, voiceNum, currentStaff, level + 1
|
|
825
|
+
xml += encodeNote(event, voiceNum, currentStaff, level + 1, false,
|
|
826
|
+
lyricXmlForNote(lyricCursors, level + 2, event.grace));
|
|
789
827
|
const dur = calculateDuration(event.duration);
|
|
790
828
|
voicePosition += dur;
|
|
791
829
|
|
|
@@ -854,10 +892,12 @@ const encodeMeasure = (
|
|
|
854
892
|
if (tupletMarks.length > 0) {
|
|
855
893
|
const origMarks = subEvent.marks;
|
|
856
894
|
subEvent.marks = [...(subEvent.marks || []), ...tupletMarks];
|
|
857
|
-
|
|
895
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1, false,
|
|
896
|
+
lyricXmlForNote(lyricCursors, level + 2, subEvent.grace));
|
|
858
897
|
subEvent.marks = origMarks;
|
|
859
898
|
} else {
|
|
860
|
-
|
|
899
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1, false,
|
|
900
|
+
lyricXmlForNote(lyricCursors, level + 2, subEvent.grace));
|
|
861
901
|
}
|
|
862
902
|
const dur = calculateDuration(subEvent.duration);
|
|
863
903
|
voicePosition += dur;
|
|
@@ -53,6 +53,15 @@ export interface MusicXmlAttributes {
|
|
|
53
53
|
|
|
54
54
|
export type MusicXmlStemDirection = 'up' | 'down';
|
|
55
55
|
|
|
56
|
+
export type MusicXmlLyricSyllabic = 'single' | 'begin' | 'middle' | 'end';
|
|
57
|
+
|
|
58
|
+
export interface MusicXmlLyric {
|
|
59
|
+
number: number;
|
|
60
|
+
text?: string;
|
|
61
|
+
syllabic?: MusicXmlLyricSyllabic;
|
|
62
|
+
extend?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
export interface MusicXmlNotations {
|
|
57
66
|
ties?: { type: 'start' | 'stop' }[];
|
|
58
67
|
slurs?: { type: 'start' | 'stop'; number: number }[];
|
|
@@ -81,6 +90,7 @@ export interface MusicXmlNote {
|
|
|
81
90
|
notations?: MusicXmlNotations;
|
|
82
91
|
fingerings?: number[]; // one per chord member; a single note has at most one
|
|
83
92
|
beams?: { type: 'begin' | 'continue' | 'end'; number: number }[];
|
|
93
|
+
lyrics?: MusicXmlLyric[];
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
// ============ Direction Types ============
|