@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
|
@@ -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;
|
package/lib/lilylet/highlight.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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) => {
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|