@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.
@@ -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
- \"([^\"\\]|\\.)*\" return 'STRING'
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
- : voice_events { $$ = [voice(currentStaff, $1)]; }
468
- | part_voices VOICE_SEP voice_events { $$ = $1.concat([voice(currentStaff, $3)]); }
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
- tremolo?: number;
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
- // Only artics remain as child elements; ornaments are control events
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 result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
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 result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
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 = (
@@ -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 {