@k-l-lambda/lilylet 0.1.82 → 0.1.84
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 +275 -213
- package/lib/lilylet/highlight.d.ts +1 -1
- package/lib/lilylet/highlight.js +8 -0
- package/lib/lilylet/meiEncoder.js +50 -8
- package/lib/lilylet/musicXmlDecoder.js +56 -0
- package/lib/lilylet/musicXmlEncoder.js +30 -4
- package/lib/lilylet/musicXmlTypes.d.ts +8 -0
- package/lib/lilylet/serializer.js +36 -0
- package/lib/lilylet/types.d.ts +11 -0
- package/package.json +2 -1
- package/source/lilylet/grammar.jison.js +275 -213
- package/source/lilylet/highlight.ts +9 -0
- package/source/lilylet/lilylet.jison +67 -5
- package/source/lilylet/meiEncoder.ts +62 -9
- package/source/lilylet/musicXmlDecoder.ts +59 -2
- package/source/lilylet/musicXmlEncoder.ts +44 -4
- package/source/lilylet/musicXmlTypes.ts +10 -0
- package/source/lilylet/serializer.ts +37 -0
- package/source/lilylet/types.ts +24 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Generic highlight scopes Lilylet tokens can carry. */
|
|
2
|
-
export type HighlightScope = "articulation" | "bar" | "brace" | "chordBracket" | "comment" | "dynamic" | "grace" | "hairpin" | "header" | "keyword" | "markup" | "mode" | "navigation" | "number" | "octave" | "operator" | "ornament" | "paren" | "pedal" | "pitch" | "punctuation" | "rest" | "separator" | "squareBracket" | "stem" | "string" | "tie" | "tuplet";
|
|
2
|
+
export type HighlightScope = "articulation" | "bar" | "brace" | "chordBracket" | "comment" | "dynamic" | "grace" | "hairpin" | "header" | "keyword" | "lyric" | "markup" | "mode" | "navigation" | "number" | "octave" | "operator" | "ornament" | "paren" | "pedal" | "pitch" | "punctuation" | "rest" | "separator" | "squareBracket" | "stem" | "string" | "tie" | "tuplet";
|
|
3
3
|
export interface HighlightRule {
|
|
4
4
|
/** Sticky, case-insensitive regex anchored at the scan position. */
|
|
5
5
|
re: RegExp;
|
package/lib/lilylet/highlight.js
CHANGED
|
@@ -50,6 +50,7 @@ export const HIGHLIGHT_RULES = [
|
|
|
50
50
|
{ re: /(?:\\segno\b)/iy, scope: "navigation" },
|
|
51
51
|
{ re: /(?:\\chords\b)/iy, scope: "keyword" },
|
|
52
52
|
{ re: /(?:\\markup\b)/iy, scope: "markup" },
|
|
53
|
+
{ re: /(?:\\addlyrics\b)/iy, scope: "lyric" },
|
|
53
54
|
{ re: /(?:\\<)/iy, scope: "hairpin" },
|
|
54
55
|
{ re: /(?:\\>)/iy, scope: "hairpin" },
|
|
55
56
|
{ re: /(?:\\!)/iy, scope: "hairpin" },
|
|
@@ -109,6 +110,13 @@ export const HIGHLIGHT_RULES = [
|
|
|
109
110
|
{ re: /(?:=)/iy, scope: "operator" },
|
|
110
111
|
{ re: /(?:[rR])/iy, scope: "rest" },
|
|
111
112
|
{ re: /(?:[sS])/iy, scope: "rest" },
|
|
113
|
+
{ re: /(?:\{)/iy, scope: "brace" },
|
|
114
|
+
{ re: /(?:\})/iy, scope: "brace" },
|
|
115
|
+
{ re: /(?:--)/iy, scope: "lyric" },
|
|
116
|
+
{ re: /(?:__\b)/iy, scope: "lyric" },
|
|
117
|
+
{ re: /(?:_\b)/iy, scope: "lyric" },
|
|
118
|
+
{ re: /(?:"([^\"\\]|\\.)*")/iy, scope: "string" },
|
|
119
|
+
{ re: /(?:[^ \t\r\n{}\"\\]+)/iy, scope: "string" },
|
|
112
120
|
];
|
|
113
121
|
/**
|
|
114
122
|
* Match a single token at `pos` in `line` using longest-match. Returns the
|
|
@@ -266,6 +266,42 @@ const tremoloToStemMod = (division) => {
|
|
|
266
266
|
}
|
|
267
267
|
return undefined;
|
|
268
268
|
};
|
|
269
|
+
const lyricWordPosition = (syllables, index) => {
|
|
270
|
+
const syllable = syllables[index];
|
|
271
|
+
if (!syllable?.text)
|
|
272
|
+
return 'u';
|
|
273
|
+
const previous = index > 0 ? syllables[index - 1] : undefined;
|
|
274
|
+
if (previous?.text && previous.hyphen)
|
|
275
|
+
return syllable.hyphen ? 'm' : 't';
|
|
276
|
+
return syllable.hyphen ? 'i' : 'u';
|
|
277
|
+
};
|
|
278
|
+
const lyricSyllableXml = (lane, index, indent) => {
|
|
279
|
+
const syllable = lane.syllables[index];
|
|
280
|
+
if (!syllable || syllable.skip || syllable.text === undefined)
|
|
281
|
+
return '';
|
|
282
|
+
const wordpos = lyricWordPosition(lane.syllables, index);
|
|
283
|
+
const con = syllable.extend ? 'u' : syllable.hyphen ? 'd' : undefined;
|
|
284
|
+
const attrs = ` wordpos="${wordpos}"${con ? ` con="${con}"` : ''}`;
|
|
285
|
+
return `${indent}<syl${attrs}>${escapeXml(syllable.text)}</syl>\n`;
|
|
286
|
+
};
|
|
287
|
+
const makeLyricCursors = (lanes, indent) => (lanes || []).map((lane) => {
|
|
288
|
+
let index = 0;
|
|
289
|
+
const consume = () => {
|
|
290
|
+
const current = index++;
|
|
291
|
+
const syllable = lane.syllables[current];
|
|
292
|
+
if (!syllable || syllable.skip || syllable.text === undefined)
|
|
293
|
+
return '';
|
|
294
|
+
const verse = lane.verse || 1;
|
|
295
|
+
return `${indent}<verse n="${verse}">\n${lyricSyllableXml(lane, current, indent + ' ')}${indent}</verse>\n`;
|
|
296
|
+
};
|
|
297
|
+
return { consume, consumeRest: () => { index++; } };
|
|
298
|
+
});
|
|
299
|
+
const lyricXmlForEvent = (cursors, indent) => {
|
|
300
|
+
let xml = '';
|
|
301
|
+
for (const cursor of cursors)
|
|
302
|
+
xml += cursor.consume();
|
|
303
|
+
return xml;
|
|
304
|
+
};
|
|
269
305
|
// Build note element
|
|
270
306
|
const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteId) => {
|
|
271
307
|
const id = noteId || generateId('note');
|
|
@@ -295,8 +331,7 @@ const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteI
|
|
|
295
331
|
if (stemMod)
|
|
296
332
|
attrs += ` stem.mod="${stemMod}"`;
|
|
297
333
|
}
|
|
298
|
-
|
|
299
|
-
const hasChildren = !inChord && options.artics && options.artics.length > 0;
|
|
334
|
+
const hasChildren = !inChord && ((options.artics && options.artics.length > 0) || !!options.lyricXml);
|
|
300
335
|
if (!hasChildren) {
|
|
301
336
|
return `${indent}<note ${attrs} />\n`;
|
|
302
337
|
}
|
|
@@ -316,6 +351,8 @@ const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteI
|
|
|
316
351
|
result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
|
|
317
352
|
}
|
|
318
353
|
}
|
|
354
|
+
if (options.lyricXml)
|
|
355
|
+
result += options.lyricXml;
|
|
319
356
|
result += `${indent}</note>\n`;
|
|
320
357
|
return result;
|
|
321
358
|
};
|
|
@@ -466,7 +503,7 @@ const extractMarkOptions = (marks) => {
|
|
|
466
503
|
return result;
|
|
467
504
|
};
|
|
468
505
|
// Convert NoteEvent to MEI
|
|
469
|
-
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
506
|
+
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals, lyricXml = '') => {
|
|
470
507
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
471
508
|
const dots = event.duration.dots || 0;
|
|
472
509
|
const markOptions = extractMarkOptions(event.marks);
|
|
@@ -503,7 +540,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
503
540
|
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
|
|
504
541
|
const noteId = generateId('note');
|
|
505
542
|
return {
|
|
506
|
-
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
543
|
+
xml: buildNoteElement(pitch, dur, dots, indent, false, { ...noteOptions, lyricXml }, noteId),
|
|
507
544
|
elementId: noteId,
|
|
508
545
|
hairpin: markOptions.hairpin,
|
|
509
546
|
pedal: markOptions.pedal,
|
|
@@ -565,6 +602,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
565
602
|
result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
|
|
566
603
|
}
|
|
567
604
|
}
|
|
605
|
+
if (lyricXml)
|
|
606
|
+
result += lyricXml;
|
|
568
607
|
result += `${indent}</chord>\n`;
|
|
569
608
|
return {
|
|
570
609
|
xml: result,
|
|
@@ -642,7 +681,7 @@ const tupletHasInternalBeams = (event) => {
|
|
|
642
681
|
return starts > 0 && starts === ends;
|
|
643
682
|
};
|
|
644
683
|
// Convert TupletEvent to MEI
|
|
645
|
-
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef) => {
|
|
684
|
+
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef, lyricCursors = []) => {
|
|
646
685
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
647
686
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
648
687
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -685,7 +724,8 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
685
724
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
686
725
|
? { ...noteEvent, staff: effectiveStaff }
|
|
687
726
|
: noteEvent;
|
|
688
|
-
const
|
|
727
|
+
const lyricXml = lyricCursors.length ? lyricXmlForEvent(lyricCursors, noteIndent + ' ') : '';
|
|
728
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals, lyricXml);
|
|
689
729
|
xml += result.xml;
|
|
690
730
|
if (!firstNoteId)
|
|
691
731
|
firstNoteId = result.elementId;
|
|
@@ -863,6 +903,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
863
903
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
864
904
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
865
905
|
const baseIndent = indent + ' ';
|
|
906
|
+
const lyricCursors = makeLyricCursors(voice.lyrics, baseIndent);
|
|
866
907
|
// Track current clef to only emit changes
|
|
867
908
|
let currentClef = initialClef;
|
|
868
909
|
// Track hairpin spans. Use a stack of open hairpins (not a single slot): the
|
|
@@ -1021,7 +1062,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1021
1062
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
1022
1063
|
? { ...noteEvent, staff: currentStaff }
|
|
1023
1064
|
: noteEvent;
|
|
1024
|
-
const
|
|
1065
|
+
const eventLyricXml = isGraceNote ? '' : lyricXmlForEvent(lyricCursors, currentIndent + ' ');
|
|
1066
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals, eventLyricXml);
|
|
1025
1067
|
xml += result.xml;
|
|
1026
1068
|
lastNoteId = result.elementId;
|
|
1027
1069
|
extendOttavaEnd(result.elementId);
|
|
@@ -1166,7 +1208,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1166
1208
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1167
1209
|
// A pending ottava shift applies to the tuplet's notes' written octave.
|
|
1168
1210
|
applyPendingOttavaShift();
|
|
1169
|
-
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1211
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef, lyricCursors);
|
|
1170
1212
|
xml += tupletResult.xml;
|
|
1171
1213
|
// An ottava span can open on, run through, or close at a tuplet (common
|
|
1172
1214
|
// in etudes where the 8va covers a whole tuplet run). Anchor the span on
|
|
@@ -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.
|
|
@@ -1442,6 +1494,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1442
1494
|
voiceMap.set(voiceNum, {
|
|
1443
1495
|
events: voiceState.events,
|
|
1444
1496
|
staff: voiceState.staff,
|
|
1497
|
+
lyrics: lyricLanes.get(voiceNum)?.lanes || new Map(),
|
|
1445
1498
|
});
|
|
1446
1499
|
}
|
|
1447
1500
|
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
@@ -1566,6 +1619,9 @@ const convertPart = (partEl) => {
|
|
|
1566
1619
|
voices.push({
|
|
1567
1620
|
staff: voiceData.staff,
|
|
1568
1621
|
events,
|
|
1622
|
+
lyrics: voiceData.lyrics.size > 0
|
|
1623
|
+
? Array.from(voiceData.lyrics.entries()).sort(([a], [b]) => a - b).map(([verse, syllables]) => ({ verse, syllables }))
|
|
1624
|
+
: undefined,
|
|
1569
1625
|
});
|
|
1570
1626
|
}
|
|
1571
1627
|
// 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;
|
|
@@ -788,6 +788,14 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
788
788
|
if (graceGroupOpen) {
|
|
789
789
|
parts.push('}');
|
|
790
790
|
}
|
|
791
|
+
// \addlyrics lanes are scoped to this voice; emit them inside the voice so
|
|
792
|
+
// they bind to THIS voice's notes (e.g. lyrics under staff 1 serialize
|
|
793
|
+
// within staff 1's voice, not as a trailing measure-level block).
|
|
794
|
+
if (voice.lyrics && voice.lyrics.length) {
|
|
795
|
+
for (const lane of voice.lyrics) {
|
|
796
|
+
parts.push(serializeLyricLane(lane));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
791
799
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
792
800
|
};
|
|
793
801
|
// Serialize a part, tracking staff state across voices
|
|
@@ -813,6 +821,34 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
|
|
|
813
821
|
// Multiple voices: separated by \\ with newline
|
|
814
822
|
return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
|
|
815
823
|
};
|
|
824
|
+
// Serialize a \addlyrics lane: syllables separated by `--` (hyphen), `_` (skip),
|
|
825
|
+
// `__` (extender). Bare words are emitted unquoted when they contain no
|
|
826
|
+
// reserved characters, otherwise they are quoted and escaped. A syllable may
|
|
827
|
+
// carry text AND a connector: the text is emitted first, then `--`/`__`, so
|
|
828
|
+
// melismas (`text __`) and hyphenation (`text --`) never lose the syllable text.
|
|
829
|
+
const serializeLyricLane = (lane) => {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const s of lane.syllables) {
|
|
832
|
+
if (s.skip) {
|
|
833
|
+
out.push('_');
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const t = s.text ?? '';
|
|
837
|
+
// An extender with no preceding text is an orphan (e.g. a leading `__`);
|
|
838
|
+
// emit it alone rather than an empty quoted string.
|
|
839
|
+
if (!t && s.extend) {
|
|
840
|
+
out.push('__');
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const bare = /^[^ \t\r\n{}"\\]+$/.test(t);
|
|
844
|
+
out.push(bare ? t : `"${escapeString(t)}"`);
|
|
845
|
+
if (s.hyphen)
|
|
846
|
+
out.push('--');
|
|
847
|
+
if (s.extend)
|
|
848
|
+
out.push('__');
|
|
849
|
+
}
|
|
850
|
+
return `\\addlyrics { ${out.join(' ')} }`;
|
|
851
|
+
};
|
|
816
852
|
// Serialize a measure, tracking staff state across parts
|
|
817
853
|
// Always output key/time at start of each measure
|
|
818
854
|
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, partStaffClefs, partEmittedClefs) => {
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -165,6 +165,16 @@ export interface Tempo {
|
|
|
165
165
|
beat?: Duration;
|
|
166
166
|
bpm?: number;
|
|
167
167
|
}
|
|
168
|
+
export interface LyricSyllable {
|
|
169
|
+
text?: string;
|
|
170
|
+
hyphen?: boolean;
|
|
171
|
+
skip?: boolean;
|
|
172
|
+
extend?: boolean;
|
|
173
|
+
}
|
|
174
|
+
export interface LyricLane {
|
|
175
|
+
verse?: number;
|
|
176
|
+
syllables: LyricSyllable[];
|
|
177
|
+
}
|
|
168
178
|
export interface NoteEvent {
|
|
169
179
|
type: 'note';
|
|
170
180
|
pitches: Pitch[];
|
|
@@ -235,6 +245,7 @@ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | Tuple
|
|
|
235
245
|
export interface Voice {
|
|
236
246
|
staff: number;
|
|
237
247
|
events: Event[];
|
|
248
|
+
lyrics?: LyricLane[];
|
|
238
249
|
}
|
|
239
250
|
export interface Metadata {
|
|
240
251
|
title?: 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.84",
|
|
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",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
42
42
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
43
43
|
"test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
|
|
44
|
+
"test:lyrics": "tsx ./tests/lyrics-roundtrip.ts",
|
|
44
45
|
"test:xml-roundtrip": "tsx ./tests/musicxml-roundtrip.ts",
|
|
45
46
|
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
46
47
|
"train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
|