@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.
@@ -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;
@@ -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
- // Only artics remain as child elements; ornaments are control events
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 result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
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 result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
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, '&apos;');
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) => {
@@ -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.82",
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",