@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.
@@ -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
@@ -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.83",
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",