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