@k-l-lambda/lilylet 0.1.83 → 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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.83",
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",
@@ -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
- xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
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
- xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
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 ============