@k-l-lambda/lilylet 0.1.81 → 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/abcDecoder.js +143 -2
- 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/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +185 -70
- package/lib/lilylet/meiEncoder.js +50 -8
- package/lib/lilylet/onsets.d.ts +53 -0
- package/lib/lilylet/onsets.js +144 -0
- package/lib/lilylet/serializer.js +36 -0
- package/lib/lilylet/types.d.ts +11 -0
- package/lib/onsets.d.ts +1 -0
- package/lib/onsets.js +1 -0
- package/package.json +7 -1
- package/source/lilylet/abcDecoder.ts +138 -2
- package/source/lilylet/grammar.jison.js +275 -213
- package/source/lilylet/highlight.ts +9 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +67 -5
- package/source/lilylet/measureLayoutFromXml.ts +190 -69
- package/source/lilylet/meiEncoder.ts +62 -9
- package/source/lilylet/onsets.ts +158 -0
- package/source/lilylet/serializer.ts +37 -0
- package/source/lilylet/types.ts +24 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note-onset extraction from a parsed Lilylet document.
|
|
3
|
+
*
|
|
4
|
+
* Walks measures -> parts -> voices -> events, accumulating each note's ONSET (position
|
|
5
|
+
* within its measure, in duration units of RES=480 per quarter) and its SOUNDING
|
|
6
|
+
* MIDI pitch. Sounding pitch honors octave-/interval-transposing clefs ("treble_8" sounds
|
|
7
|
+
* an octave lower than written, etc.), tracked per voice and PERSISTED across measures.
|
|
8
|
+
*
|
|
9
|
+
* Shared by tools/astServer.ts (the HTTP onset API) and the clef-onset unit test.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { calculateDuration } from "./musicXmlUtils";
|
|
13
|
+
import { Accidental } from "./types";
|
|
14
|
+
|
|
15
|
+
// Onset resolution in ticks per quarter note. calculateDuration() rounds its result to an
|
|
16
|
+
// integer, so it must run at a resolution fine enough to represent every note value exactly:
|
|
17
|
+
// the shared DIVISIONS=4 (MusicXML export) rounds a 32nd (0.5) and a dotted 16th (1.5) to
|
|
18
|
+
// integers, drifting the onset cursor. 480 is divisible by all common note/dot/tuplet values,
|
|
19
|
+
// so durations stay exact. Onsets are reported in these units and also normalized per measure.
|
|
20
|
+
const RES = 480;
|
|
21
|
+
|
|
22
|
+
const PHONET_SEMITONE: Record<string, number> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|
23
|
+
const ACCIDENTAL_SEMITONE: Record<string, number> = {
|
|
24
|
+
[Accidental.natural]: 0, [Accidental.sharp]: 1, [Accidental.flat]: -1,
|
|
25
|
+
[Accidental.doubleSharp]: 2, [Accidental.doubleFlat]: -2,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Diatonic-step -> semitones within an octave (unison..seventh), for "_N"/"^N" clef suffixes.
|
|
29
|
+
const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
|
|
33
|
+
* "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
|
|
34
|
+
* (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
|
|
35
|
+
* A plain clef (no suffix) declares 0.
|
|
36
|
+
*/
|
|
37
|
+
export const clefShift = (clefStr: string): number => {
|
|
38
|
+
const m = /^.*?([_^])(\d+)$/.exec(clefStr || "");
|
|
39
|
+
if (!m) return 0;
|
|
40
|
+
const k = parseInt(m[2], 10) - 1; // diatonic steps above unison
|
|
41
|
+
const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
|
|
42
|
+
return (m[1] === "^" ? 1 : -1) * semis;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C
|
|
47
|
+
* octave = MIDI 60 (see parser.resolveRelativePitch). `shift` is the active clef's
|
|
48
|
+
* written->sounding semitone transposition.
|
|
49
|
+
*/
|
|
50
|
+
export const pitchToMidi = (pitch: any, shift = 0): number => {
|
|
51
|
+
const semi = PHONET_SEMITONE[pitch.phonet] ?? 0;
|
|
52
|
+
const acc = pitch.accidental ? (ACCIDENTAL_SEMITONE[pitch.accidental] ?? 0) : 0;
|
|
53
|
+
return 60 + (pitch.octave || 0) * 12 + semi + acc + shift;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface NoteOnset {
|
|
57
|
+
onset: number; onsetNorm: number; durationDiv: number;
|
|
58
|
+
midi: number[]; staff: number; voice: number; grace: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MeasureOnsets {
|
|
62
|
+
index: number;
|
|
63
|
+
timeSig: { numerator: number; denominator: number } | null;
|
|
64
|
+
measureDivisions: number;
|
|
65
|
+
notes: NoteOnset[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Walk a voice's events, accumulating onset in duration units. Tuplet/times scale their inner
|
|
69
|
+
// durations by ratio (num/den). Grace notes take no time. `clefRef.shift` is the active clef's
|
|
70
|
+
// written->sounding shift, updated by \clef contextChanges (persisted by the caller across
|
|
71
|
+
// measures). Returns the total consumed duration (the voice's played length).
|
|
72
|
+
const walkVoice = (events: any[], staff: number, voice: number,
|
|
73
|
+
scale: number, startCursor: number, clefRef: { shift: number }, out: NoteOnset[]): number => {
|
|
74
|
+
let cursor = startCursor;
|
|
75
|
+
for (const ev of events) {
|
|
76
|
+
if (ev.type === "context" && typeof ev.clef === "string") {
|
|
77
|
+
clefRef.shift = clefShift(ev.clef);
|
|
78
|
+
}
|
|
79
|
+
else if (ev.type === "note") {
|
|
80
|
+
const dur = calculateDuration(ev.duration, RES) * scale;
|
|
81
|
+
const midi = (ev.pitches || []).map((p: any) => pitchToMidi(p, clefRef.shift));
|
|
82
|
+
if (ev.grace) {
|
|
83
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: 0, midi, staff: ev.staff ?? staff, voice, grace: true });
|
|
84
|
+
continue; // grace steals no measure time
|
|
85
|
+
}
|
|
86
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: dur, midi, staff: ev.staff ?? staff, voice, grace: false });
|
|
87
|
+
cursor += dur;
|
|
88
|
+
}
|
|
89
|
+
else if (ev.type === "rest") {
|
|
90
|
+
cursor += calculateDuration(ev.duration, RES) * scale;
|
|
91
|
+
}
|
|
92
|
+
else if (ev.type === "tuplet" || ev.type === "times") {
|
|
93
|
+
const r = ev.ratio || { numerator: 1, denominator: 1 };
|
|
94
|
+
cursor = walkVoice(ev.events || [], staff, voice, scale * (r.numerator / r.denominator), cursor, clefRef, out);
|
|
95
|
+
}
|
|
96
|
+
else if (ev.type === "tremolo") {
|
|
97
|
+
const each = (RES * 4 / ev.division) * scale;
|
|
98
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
|
|
99
|
+
midi: (ev.pitchA || []).map((p: any) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
|
|
100
|
+
cursor += each;
|
|
101
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
|
|
102
|
+
midi: (ev.pitchB || []).map((p: any) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
|
|
103
|
+
cursor += each;
|
|
104
|
+
}
|
|
105
|
+
// barline / markup / dynamic / harmony / pitchReset: no time
|
|
106
|
+
}
|
|
107
|
+
return cursor;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-measure note onsets for a parsed LilyletDoc. The active clef transposition is tracked
|
|
112
|
+
* per (positional) voice index and PERSISTS across measures — a \clef stays in force until the
|
|
113
|
+
* next one, so a "treble_8" set in measure 1 still lowers measure 2 by an octave.
|
|
114
|
+
*
|
|
115
|
+
* Measure length (`measureDivisions`, used to normalize onsets to [0,1)) comes from the time
|
|
116
|
+
* signature for a full bar, but an INCOMPLETE bar carrying a `\partial` (a pickup, or a bar
|
|
117
|
+
* split by a repeat barline) is only as long as its `\partial` value. Honoring `\partial` makes
|
|
118
|
+
* the lilylet bar length match the corresponding MIDI measure's tick span (MuseScore emits a
|
|
119
|
+
* short 1/8 or 3/8 measure for exactly these), so the two onset grids align.
|
|
120
|
+
*/
|
|
121
|
+
export const measureOnsets = (doc: any): MeasureOnsets[] => {
|
|
122
|
+
let curTime: any = null;
|
|
123
|
+
const clefByVoice: { shift: number }[] = [];
|
|
124
|
+
return (doc.measures || []).map((m: any, mi: number): MeasureOnsets => {
|
|
125
|
+
if (m.timeSig) curTime = m.timeSig;
|
|
126
|
+
const notes: NoteOnset[] = [];
|
|
127
|
+
let maxCursor = 0;
|
|
128
|
+
let vIndex = 0;
|
|
129
|
+
// a \partial anywhere in the measure (any voice's leading contextChange) shortens the bar.
|
|
130
|
+
let partial: any = null;
|
|
131
|
+
for (const part of m.parts || []) {
|
|
132
|
+
for (const voice of part.voices || []) {
|
|
133
|
+
if (!clefByVoice[vIndex]) clefByVoice[vIndex] = { shift: 0 };
|
|
134
|
+
for (const ev of voice.events || []) {
|
|
135
|
+
if (ev.type === "context" && ev.partial) { partial = ev.partial; break; }
|
|
136
|
+
if (ev.type === "note" || ev.type === "rest" || ev.type === "tuplet"
|
|
137
|
+
|| ev.type === "times" || ev.type === "tremolo") break; // partial must precede notes
|
|
138
|
+
}
|
|
139
|
+
const end = walkVoice(voice.events || [], voice.staff ?? 1, vIndex, 1, 0, clefByVoice[vIndex], notes);
|
|
140
|
+
maxCursor = Math.max(maxCursor, end);
|
|
141
|
+
vIndex += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// bar length: \partial value if the bar is incomplete, else the full time-signature bar,
|
|
145
|
+
// else (no time sig) the longest voice cursor.
|
|
146
|
+
const fullBar = curTime ? RES * 4 * curTime.numerator / curTime.denominator : maxCursor;
|
|
147
|
+
const barDiv = partial ? calculateDuration(partial, RES) : fullBar;
|
|
148
|
+
const span = barDiv > 0 ? barDiv : (maxCursor > 0 ? maxCursor : 1);
|
|
149
|
+
for (const n of notes)
|
|
150
|
+
n.onsetNorm = n.onset / span;
|
|
151
|
+
return {
|
|
152
|
+
index: mi + 1,
|
|
153
|
+
timeSig: curTime ? { numerator: curTime.numerator, denominator: curTime.denominator } : null,
|
|
154
|
+
measureDivisions: span,
|
|
155
|
+
notes,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
};
|
|
@@ -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 = (
|
package/source/lilylet/types.ts
CHANGED
|
@@ -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 {
|