@k-l-lambda/lilylet 0.1.82 → 0.1.83
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/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/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
|
|
@@ -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 {
|