@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
|
@@ -18,6 +18,7 @@ export type HighlightScope =
|
|
|
18
18
|
| "hairpin"
|
|
19
19
|
| "header"
|
|
20
20
|
| "keyword"
|
|
21
|
+
| "lyric"
|
|
21
22
|
| "markup"
|
|
22
23
|
| "mode"
|
|
23
24
|
| "navigation"
|
|
@@ -96,6 +97,7 @@ export const HIGHLIGHT_RULES: HighlightRule[] = [
|
|
|
96
97
|
{ re: /(?:\\segno\b)/iy, scope: "navigation" },
|
|
97
98
|
{ re: /(?:\\chords\b)/iy, scope: "keyword" },
|
|
98
99
|
{ re: /(?:\\markup\b)/iy, scope: "markup" },
|
|
100
|
+
{ re: /(?:\\addlyrics\b)/iy, scope: "lyric" },
|
|
99
101
|
{ re: /(?:\\<)/iy, scope: "hairpin" },
|
|
100
102
|
{ re: /(?:\\>)/iy, scope: "hairpin" },
|
|
101
103
|
{ re: /(?:\\!)/iy, scope: "hairpin" },
|
|
@@ -155,6 +157,13 @@ export const HIGHLIGHT_RULES: HighlightRule[] = [
|
|
|
155
157
|
{ re: /(?:=)/iy, scope: "operator" },
|
|
156
158
|
{ re: /(?:[rR])/iy, scope: "rest" },
|
|
157
159
|
{ re: /(?:[sS])/iy, scope: "rest" },
|
|
160
|
+
{ re: /(?:\{)/iy, scope: "brace" },
|
|
161
|
+
{ re: /(?:\})/iy, scope: "brace" },
|
|
162
|
+
{ re: /(?:--)/iy, scope: "lyric" },
|
|
163
|
+
{ re: /(?:__\b)/iy, scope: "lyric" },
|
|
164
|
+
{ re: /(?:_\b)/iy, scope: "lyric" },
|
|
165
|
+
{ re: /(?:"([^\"\\]|\\.)*")/iy, scope: "string" },
|
|
166
|
+
{ re: /(?:[^ \t\r\n{}\"\\]+)/iy, scope: "string" },
|
|
158
167
|
];
|
|
159
168
|
|
|
160
169
|
/**
|
|
@@ -34,6 +34,20 @@
|
|
|
34
34
|
};
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
// Build a lyric lane (one \addlyrics block) from raw lyric items. `--` is a
|
|
38
|
+
// connector that hyphenates the preceding syllable (it does not consume a
|
|
39
|
+
// note slot); `_` is a skipped slot; `__` extends the preceding syllable.
|
|
40
|
+
const addlyricsLane = (items) => {
|
|
41
|
+
const syllables = [];
|
|
42
|
+
for (const it of items) {
|
|
43
|
+
if (it.text !== undefined) syllables.push({ text: it.text });
|
|
44
|
+
else if (it.hyphen) { if (syllables.length) syllables[syllables.length - 1].hyphen = true; }
|
|
45
|
+
else if (it.skip) syllables.push({ skip: true });
|
|
46
|
+
else if (it.extend) { if (syllables.length) syllables[syllables.length - 1].extend = true; else syllables.push({ extend: true }); }
|
|
47
|
+
}
|
|
48
|
+
return { syllables };
|
|
49
|
+
};
|
|
50
|
+
|
|
37
51
|
const restEvent = (dur, options = {}) => ({
|
|
38
52
|
type: 'rest',
|
|
39
53
|
duration: dur,
|
|
@@ -51,7 +65,7 @@
|
|
|
51
65
|
mode,
|
|
52
66
|
});
|
|
53
67
|
|
|
54
|
-
const voice = (staff, events) => {
|
|
68
|
+
const voice = (staff, events, lyrics) => {
|
|
55
69
|
// Use the first \staff "N" context event that appears before any musical event
|
|
56
70
|
// (notes/rests/tuplets) as the authoritative voice.staff.
|
|
57
71
|
// Skip pitchReset events (from NEWLINE tokens at voice-line boundaries).
|
|
@@ -67,9 +81,13 @@
|
|
|
67
81
|
}
|
|
68
82
|
break; // first musical/structural event — stop
|
|
69
83
|
}
|
|
84
|
+
// \addlyrics lanes are scoped to THIS voice; verse numbers are 1-based by
|
|
85
|
+
// order of \addlyrics blocks within the voice.
|
|
86
|
+
const lanes = lyrics && lyrics.length ? lyrics.map((lane, i) => ({ verse: i + 1, ...lane })) : undefined;
|
|
70
87
|
return {
|
|
71
88
|
staff: leadingStaff != null ? leadingStaff : 1,
|
|
72
89
|
events,
|
|
90
|
+
lyrics: lanes,
|
|
73
91
|
};
|
|
74
92
|
};
|
|
75
93
|
|
|
@@ -241,6 +259,7 @@
|
|
|
241
259
|
|
|
242
260
|
%option flex unicode case-insensitive
|
|
243
261
|
|
|
262
|
+
%x lyric
|
|
244
263
|
|
|
245
264
|
%%
|
|
246
265
|
|
|
@@ -262,7 +281,7 @@
|
|
|
262
281
|
\[auto\-beam return 'HEADER_AUTOBEAM'
|
|
263
282
|
\] return ']'
|
|
264
283
|
|
|
265
|
-
\"([^\"\\]|\\.)*\"
|
|
284
|
+
\"([^\"\\]|\\.)*\" return 'STRING'
|
|
266
285
|
|
|
267
286
|
"\\clef" return 'CMD_CLEF'
|
|
268
287
|
"\\key" return 'CMD_KEY'
|
|
@@ -292,6 +311,7 @@
|
|
|
292
311
|
"\\segno" return 'CMD_SEGNO'
|
|
293
312
|
"\\chords" return 'CMD_CHORDS'
|
|
294
313
|
"\\markup" return 'CMD_MARKUP'
|
|
314
|
+
"\\addlyrics" { this.pushState('lyric'); return 'CMD_ADDLYRICS'; }
|
|
295
315
|
|
|
296
316
|
"\\<" return 'CMD_CRESC_BEGIN'
|
|
297
317
|
"\\>" return 'CMD_DIM_BEGIN'
|
|
@@ -369,6 +389,17 @@
|
|
|
369
389
|
. {}
|
|
370
390
|
|
|
371
391
|
|
|
392
|
+
<lyric>"{" return '{'
|
|
393
|
+
<lyric>"}" { this.popState(); return '}'; }
|
|
394
|
+
<lyric>[ \t]+ {}
|
|
395
|
+
<lyric>(\r?\n)+ {}
|
|
396
|
+
<lyric>"--" return 'LYRIC_HYPHEN'
|
|
397
|
+
<lyric>"__" return 'LYRIC_EXTENDER'
|
|
398
|
+
<lyric>"_" return 'LYRIC_SKIP'
|
|
399
|
+
<lyric>\"([^\"\\]|\\.)*\" return 'LYRIC_WORD'
|
|
400
|
+
<lyric>[^ \t\r\n{}\"\\]+ return 'LYRIC_WORD'
|
|
401
|
+
|
|
402
|
+
|
|
372
403
|
/lex
|
|
373
404
|
|
|
374
405
|
%start document
|
|
@@ -423,7 +454,7 @@ measures
|
|
|
423
454
|
;
|
|
424
455
|
|
|
425
456
|
measure_content
|
|
426
|
-
: parts
|
|
457
|
+
: parts %{
|
|
427
458
|
// Check \partial declarations: warn if declared duration ≠ actual voice ticks
|
|
428
459
|
let partialDur = null;
|
|
429
460
|
outer: for (const p of $1) {
|
|
@@ -454,6 +485,17 @@ measure_content
|
|
|
454
485
|
%}
|
|
455
486
|
;
|
|
456
487
|
|
|
488
|
+
addlyrics_opt
|
|
489
|
+
: /* empty */ { $$ = undefined; }
|
|
490
|
+
| addlyrics_blocks { $$ = $1; }
|
|
491
|
+
;
|
|
492
|
+
|
|
493
|
+
addlyrics_blocks
|
|
494
|
+
: addlyrics_block { $$ = [$1]; }
|
|
495
|
+
| addlyrics_blocks addlyrics_block { $$ = $1.concat([$2]); }
|
|
496
|
+
| addlyrics_blocks NEWLINE { $$ = $1; }
|
|
497
|
+
;
|
|
498
|
+
|
|
457
499
|
parts
|
|
458
500
|
: part_voices { $$ = [part($1)]; }
|
|
459
501
|
| parts PART_SEP part_start part_voices { $$ = $1.concat([part($4)]); }
|
|
@@ -464,8 +506,12 @@ part_start
|
|
|
464
506
|
;
|
|
465
507
|
|
|
466
508
|
part_voices
|
|
467
|
-
:
|
|
468
|
-
| part_voices VOICE_SEP
|
|
509
|
+
: voice_block { $$ = [voice(currentStaff, $1.events, $1.lyrics)]; }
|
|
510
|
+
| part_voices VOICE_SEP voice_block { $$ = $1.concat([voice(currentStaff, $3.events, $3.lyrics)]); }
|
|
511
|
+
;
|
|
512
|
+
|
|
513
|
+
voice_block
|
|
514
|
+
: voice_events addlyrics_opt { $$ = { events: $1, lyrics: $2 }; }
|
|
469
515
|
;
|
|
470
516
|
|
|
471
517
|
voice_events
|
|
@@ -787,3 +833,19 @@ markup_mark
|
|
|
787
833
|
glissando_mark
|
|
788
834
|
: CMD_GLISSANDO -> glissando()
|
|
789
835
|
;
|
|
836
|
+
|
|
837
|
+
addlyrics_block
|
|
838
|
+
: CMD_ADDLYRICS '{' lyric_sequence '}' -> addlyricsLane($3)
|
|
839
|
+
;
|
|
840
|
+
|
|
841
|
+
lyric_sequence
|
|
842
|
+
: /* empty */ { $$ = []; }
|
|
843
|
+
| lyric_sequence lyric_item { $$ = $1.concat([$2]); }
|
|
844
|
+
;
|
|
845
|
+
|
|
846
|
+
lyric_item
|
|
847
|
+
: LYRIC_WORD -> ({ text: $1.charAt(0) === '"' ? unq($1) : $1 })
|
|
848
|
+
| LYRIC_HYPHEN -> ({ hyphen: true })
|
|
849
|
+
| LYRIC_SKIP -> ({ skip: true })
|
|
850
|
+
| LYRIC_EXTENDER -> ({ extend: true })
|
|
851
|
+
;
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
Tempo,
|
|
27
27
|
Event,
|
|
28
28
|
InstrumentName,
|
|
29
|
+
LyricLane,
|
|
30
|
+
LyricSyllable,
|
|
29
31
|
} from "./types";
|
|
30
32
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
33
|
import { gmProgramOf } from "./gmInstruments";
|
|
@@ -327,6 +329,49 @@ const tremoloToStemMod = (division: number): string | undefined => {
|
|
|
327
329
|
return undefined;
|
|
328
330
|
};
|
|
329
331
|
|
|
332
|
+
// Encode the lyric slots attached to one rhythmic event. Lanes are measure-local
|
|
333
|
+
// because the parser stores them on the Voice created for that measure.
|
|
334
|
+
interface LyricCursor {
|
|
335
|
+
consume(): string;
|
|
336
|
+
consumeRest(): void;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const lyricWordPosition = (syllables: LyricSyllable[], index: number): 'i' | 'm' | 't' | 'u' => {
|
|
340
|
+
const syllable = syllables[index];
|
|
341
|
+
if (!syllable?.text) return 'u';
|
|
342
|
+
const previous = index > 0 ? syllables[index - 1] : undefined;
|
|
343
|
+
if (previous?.text && previous.hyphen) return syllable.hyphen ? 'm' : 't';
|
|
344
|
+
return syllable.hyphen ? 'i' : 'u';
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const lyricSyllableXml = (lane: LyricLane, index: number, indent: string): string => {
|
|
348
|
+
const syllable = lane.syllables[index];
|
|
349
|
+
if (!syllable || syllable.skip || syllable.text === undefined) return '';
|
|
350
|
+
const wordpos = lyricWordPosition(lane.syllables, index);
|
|
351
|
+
const con = syllable.extend ? 'u' : syllable.hyphen ? 'd' : undefined;
|
|
352
|
+
const attrs = ` wordpos="${wordpos}"${con ? ` con="${con}"` : ''}`;
|
|
353
|
+
return `${indent}<syl${attrs}>${escapeXml(syllable.text)}</syl>\n`;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const makeLyricCursors = (lanes: LyricLane[] | undefined, indent: string): LyricCursor[] =>
|
|
357
|
+
(lanes || []).map((lane) => {
|
|
358
|
+
let index = 0;
|
|
359
|
+
const consume = (): string => {
|
|
360
|
+
const current = index++;
|
|
361
|
+
const syllable = lane.syllables[current];
|
|
362
|
+
if (!syllable || syllable.skip || syllable.text === undefined) return '';
|
|
363
|
+
const verse = lane.verse || 1;
|
|
364
|
+
return `${indent}<verse n="${verse}">\n${lyricSyllableXml(lane, current, indent + ' ')}${indent}</verse>\n`;
|
|
365
|
+
};
|
|
366
|
+
return { consume, consumeRest: () => { index++; } };
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const lyricXmlForEvent = (cursors: LyricCursor[], indent: string): string => {
|
|
370
|
+
let xml = '';
|
|
371
|
+
for (const cursor of cursors) xml += cursor.consume();
|
|
372
|
+
return xml;
|
|
373
|
+
};
|
|
374
|
+
|
|
330
375
|
// Build note element
|
|
331
376
|
const buildNoteElement = (
|
|
332
377
|
pitch: { pname: string; oct: number; octGes?: number; accid?: string; accidGes?: string },
|
|
@@ -341,7 +386,8 @@ const buildNoteElement = (
|
|
|
341
386
|
staff?: number;
|
|
342
387
|
layerStaff?: number;
|
|
343
388
|
artics?: { type: string; placement?: 'above' | 'below' }[];
|
|
344
|
-
|
|
389
|
+
tremolo?: number;
|
|
390
|
+
lyricXml?: string;
|
|
345
391
|
} = {},
|
|
346
392
|
noteId?: string
|
|
347
393
|
): string => {
|
|
@@ -366,8 +412,7 @@ const buildNoteElement = (
|
|
|
366
412
|
if (stemMod) attrs += ` stem.mod="${stemMod}"`;
|
|
367
413
|
}
|
|
368
414
|
|
|
369
|
-
|
|
370
|
-
const hasChildren = !inChord && options.artics && options.artics.length > 0;
|
|
415
|
+
const hasChildren = !inChord && ((options.artics && options.artics.length > 0) || !!options.lyricXml);
|
|
371
416
|
|
|
372
417
|
if (!hasChildren) {
|
|
373
418
|
return `${indent}<note ${attrs} />\n`;
|
|
@@ -392,6 +437,8 @@ const buildNoteElement = (
|
|
|
392
437
|
}
|
|
393
438
|
}
|
|
394
439
|
|
|
440
|
+
if (options.lyricXml) result += options.lyricXml;
|
|
441
|
+
|
|
395
442
|
result += `${indent}</note>\n`;
|
|
396
443
|
return result;
|
|
397
444
|
};
|
|
@@ -592,7 +639,8 @@ const noteEventToMEI = (
|
|
|
592
639
|
contextStemDir?: StemDirection,
|
|
593
640
|
keyFifths: number = 0,
|
|
594
641
|
ottavaShift: number = 0,
|
|
595
|
-
measureAccidentals?: Map<string, string
|
|
642
|
+
measureAccidentals?: Map<string, string>,
|
|
643
|
+
lyricXml: string = ''
|
|
596
644
|
): NoteEventResult => {
|
|
597
645
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
598
646
|
const dots = event.duration.dots || 0;
|
|
@@ -630,7 +678,7 @@ const noteEventToMEI = (
|
|
|
630
678
|
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
|
|
631
679
|
const noteId = generateId('note');
|
|
632
680
|
return {
|
|
633
|
-
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
681
|
+
xml: buildNoteElement(pitch, dur, dots, indent, false, { ...noteOptions, lyricXml }, noteId),
|
|
634
682
|
elementId: noteId,
|
|
635
683
|
hairpin: markOptions.hairpin,
|
|
636
684
|
pedal: markOptions.pedal,
|
|
@@ -693,6 +741,7 @@ const noteEventToMEI = (
|
|
|
693
741
|
}
|
|
694
742
|
}
|
|
695
743
|
|
|
744
|
+
if (lyricXml) result += lyricXml;
|
|
696
745
|
result += `${indent}</chord>\n`;
|
|
697
746
|
return {
|
|
698
747
|
xml: result,
|
|
@@ -795,7 +844,7 @@ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
|
|
|
795
844
|
};
|
|
796
845
|
|
|
797
846
|
// Convert TupletEvent to MEI
|
|
798
|
-
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>, currentClef?: string): TupletEventResult => {
|
|
847
|
+
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>, currentClef?: string, lyricCursors: LyricCursor[] = []): TupletEventResult => {
|
|
799
848
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
800
849
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
801
850
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -847,7 +896,8 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
847
896
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
848
897
|
? { ...noteEvent, staff: effectiveStaff }
|
|
849
898
|
: noteEvent;
|
|
850
|
-
const
|
|
899
|
+
const lyricXml = lyricCursors.length ? lyricXmlForEvent(lyricCursors, noteIndent + ' ') : '';
|
|
900
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals, lyricXml);
|
|
851
901
|
xml += result.xml;
|
|
852
902
|
|
|
853
903
|
if (!firstNoteId) firstNoteId = result.elementId;
|
|
@@ -1152,6 +1202,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1152
1202
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
1153
1203
|
const baseIndent = indent + ' ';
|
|
1154
1204
|
|
|
1205
|
+
const lyricCursors = makeLyricCursors(voice.lyrics, baseIndent);
|
|
1206
|
+
|
|
1155
1207
|
// Track current clef to only emit changes
|
|
1156
1208
|
let currentClef: Clef | undefined = initialClef;
|
|
1157
1209
|
|
|
@@ -1327,7 +1379,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1327
1379
|
? { ...noteEvent, staff: currentStaff }
|
|
1328
1380
|
: noteEvent;
|
|
1329
1381
|
|
|
1330
|
-
const
|
|
1382
|
+
const eventLyricXml = isGraceNote ? '' : lyricXmlForEvent(lyricCursors, currentIndent + ' ');
|
|
1383
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals, eventLyricXml);
|
|
1331
1384
|
xml += result.xml;
|
|
1332
1385
|
lastNoteId = result.elementId;
|
|
1333
1386
|
extendOttavaEnd(result.elementId);
|
|
@@ -1472,7 +1525,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1472
1525
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1473
1526
|
// A pending ottava shift applies to the tuplet's notes' written octave.
|
|
1474
1527
|
applyPendingOttavaShift();
|
|
1475
|
-
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1528
|
+
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef, lyricCursors);
|
|
1476
1529
|
xml += tupletResult.xml;
|
|
1477
1530
|
|
|
1478
1531
|
// An ottava span can open on, run through, or close at a tuplet (common
|
|
@@ -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.
|
|
@@ -1687,11 +1740,12 @@ const convertMeasure = (
|
|
|
1687
1740
|
}
|
|
1688
1741
|
|
|
1689
1742
|
// Build voice map from tracker
|
|
1690
|
-
const voiceMap = new Map<number, { events: Event[]; staff: number }>();
|
|
1743
|
+
const voiceMap = new Map<number, { events: Event[]; staff: number; lyrics: Map<number, LyricSyllable[]> }>();
|
|
1691
1744
|
for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
|
|
1692
1745
|
voiceMap.set(voiceNum, {
|
|
1693
1746
|
events: voiceState.events,
|
|
1694
1747
|
staff: voiceState.staff,
|
|
1748
|
+
lyrics: lyricLanes.get(voiceNum)?.lanes || new Map(),
|
|
1695
1749
|
});
|
|
1696
1750
|
}
|
|
1697
1751
|
|
|
@@ -1825,6 +1879,9 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1825
1879
|
voices.push({
|
|
1826
1880
|
staff: voiceData.staff,
|
|
1827
1881
|
events,
|
|
1882
|
+
lyrics: voiceData.lyrics.size > 0
|
|
1883
|
+
? Array.from(voiceData.lyrics.entries()).sort(([a], [b]) => a - b).map(([verse, syllables]) => ({ verse, syllables }))
|
|
1884
|
+
: undefined,
|
|
1828
1885
|
});
|
|
1829
1886
|
}
|
|
1830
1887
|
|
|
@@ -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 ============
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
StemDirection,
|
|
29
29
|
Accidental,
|
|
30
30
|
ArticulationType,
|
|
31
|
+
LyricLane,
|
|
31
32
|
OrnamentType,
|
|
32
33
|
DynamicType,
|
|
33
34
|
HairpinType,
|
|
@@ -928,6 +929,15 @@ const serializeVoice = (
|
|
|
928
929
|
parts.push('}');
|
|
929
930
|
}
|
|
930
931
|
|
|
932
|
+
// \addlyrics lanes are scoped to this voice; emit them inside the voice so
|
|
933
|
+
// they bind to THIS voice's notes (e.g. lyrics under staff 1 serialize
|
|
934
|
+
// within staff 1's voice, not as a trailing measure-level block).
|
|
935
|
+
if (voice.lyrics && voice.lyrics.length) {
|
|
936
|
+
for (const lane of voice.lyrics) {
|
|
937
|
+
parts.push(serializeLyricLane(lane));
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
931
941
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
932
942
|
};
|
|
933
943
|
|
|
@@ -969,6 +979,33 @@ const serializePart = (
|
|
|
969
979
|
};
|
|
970
980
|
|
|
971
981
|
|
|
982
|
+
// Serialize a \addlyrics lane: syllables separated by `--` (hyphen), `_` (skip),
|
|
983
|
+
// `__` (extender). Bare words are emitted unquoted when they contain no
|
|
984
|
+
// reserved characters, otherwise they are quoted and escaped. A syllable may
|
|
985
|
+
// carry text AND a connector: the text is emitted first, then `--`/`__`, so
|
|
986
|
+
// melismas (`text __`) and hyphenation (`text --`) never lose the syllable text.
|
|
987
|
+
const serializeLyricLane = (lane: LyricLane): string => {
|
|
988
|
+
const out: string[] = [];
|
|
989
|
+
for (const s of lane.syllables) {
|
|
990
|
+
if (s.skip) {
|
|
991
|
+
out.push('_');
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
const t = s.text ?? '';
|
|
995
|
+
// An extender with no preceding text is an orphan (e.g. a leading `__`);
|
|
996
|
+
// emit it alone rather than an empty quoted string.
|
|
997
|
+
if (!t && s.extend) {
|
|
998
|
+
out.push('__');
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const bare = /^[^ \t\r\n{}"\\]+$/.test(t);
|
|
1002
|
+
out.push(bare ? t : `"${escapeString(t)}"`);
|
|
1003
|
+
if (s.hyphen) out.push('--');
|
|
1004
|
+
if (s.extend) out.push('__');
|
|
1005
|
+
}
|
|
1006
|
+
return `\\addlyrics { ${out.join(' ')} }`;
|
|
1007
|
+
};
|
|
1008
|
+
|
|
972
1009
|
// Serialize a measure, tracking staff state across parts
|
|
973
1010
|
// Always output key/time at start of each measure
|
|
974
1011
|
const serializeMeasure = (
|
package/source/lilylet/types.ts
CHANGED
|
@@ -214,6 +214,26 @@ export interface Tempo {
|
|
|
214
214
|
bpm?: number;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// === Lyrics ===
|
|
218
|
+
|
|
219
|
+
// A lyric syllable within a lane, aligned by order to a voice's note events
|
|
220
|
+
// (like LilyPond \lyricsto / \addlyrics). `hyphen` means the syllable is joined
|
|
221
|
+
// to the next with a hyphen (LilyPond `--`); `skip` advances past a note with
|
|
222
|
+
// no syllable (LilyPond `_`); `extend` draws an extender line (LilyPond `__`).
|
|
223
|
+
export interface LyricSyllable {
|
|
224
|
+
text?: string;
|
|
225
|
+
hyphen?: boolean;
|
|
226
|
+
skip?: boolean;
|
|
227
|
+
extend?: boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A lyrics lane (verse) scoped to a voice. Verse numbers are 1-based and
|
|
231
|
+
// assigned by order of \addlyrics blocks within the voice.
|
|
232
|
+
export interface LyricLane {
|
|
233
|
+
verse?: number;
|
|
234
|
+
syllables: LyricSyllable[];
|
|
235
|
+
}
|
|
236
|
+
|
|
217
237
|
// === Events ===
|
|
218
238
|
|
|
219
239
|
export interface NoteEvent {
|
|
@@ -301,6 +321,10 @@ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | Tuple
|
|
|
301
321
|
export interface Voice {
|
|
302
322
|
staff: number;
|
|
303
323
|
events: Event[];
|
|
324
|
+
// \addlyrics lanes scoped to this voice. Lyrics align to THIS voice's note
|
|
325
|
+
// events, so a lane is serialized inside the voice it belongs to (e.g. a
|
|
326
|
+
// vocal line under staff 1), not as a trailing measure-level block.
|
|
327
|
+
lyrics?: LyricLane[];
|
|
304
328
|
}
|
|
305
329
|
|
|
306
330
|
export interface Metadata {
|