@k-l-lambda/lilylet 0.1.76 → 0.1.79

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.
@@ -32,6 +32,7 @@ import {
32
32
  DynamicType,
33
33
  HairpinType,
34
34
  PedalType,
35
+ NavigationMarkType,
35
36
  Tempo,
36
37
  Metadata,
37
38
  Fraction,
@@ -124,6 +125,7 @@ const DYNAMIC_TO_XML: Record<string, string> = {
124
125
  fff: 'fff',
125
126
  sfz: 'sfz',
126
127
  rfz: 'rfz',
128
+ fp: 'fp',
127
129
  };
128
130
 
129
131
  // Barline style to MusicXML
@@ -289,6 +291,7 @@ const encodeAttributes = (
289
291
  const encodeNotations = (marks: Mark[], level: number): string => {
290
292
  const articulations: string[] = [];
291
293
  const ornaments: string[] = [];
294
+ const technical: string[] = [];
292
295
  const otherNotations: string[] = [];
293
296
 
294
297
  for (const mark of marks) {
@@ -301,15 +304,20 @@ const encodeNotations = (marks: Mark[], level: number): string => {
301
304
  break;
302
305
 
303
306
  case 'ornament':
304
- const ornXml = ORNAMENT_TO_XML[mark.type];
305
- if (ornXml) {
306
- if (mark.type === 'fermata') {
307
- otherNotations.push('<fermata/>');
308
- } else if (mark.type === 'arpeggio') {
309
- otherNotations.push('<arpeggiate/>');
310
- } else {
311
- ornaments.push(`<${ornXml}/>`);
312
- }
307
+ // fermata / arpeggio live at the <notations> level; breath-mark is an
308
+ // <articulations> child; the rest are true <ornaments>. (shortFermata is
309
+ // a fermata with an "angled" shape — decoder maps it back.)
310
+ if (mark.type === 'fermata') {
311
+ otherNotations.push('<fermata/>');
312
+ } else if (mark.type === 'shortFermata') {
313
+ otherNotations.push('<fermata>angled</fermata>');
314
+ } else if (mark.type === 'arpeggio') {
315
+ otherNotations.push('<arpeggiate/>');
316
+ } else if (mark.type === 'breath') {
317
+ articulations.push('<breath-mark/>');
318
+ } else {
319
+ const ornXml = ORNAMENT_TO_XML[mark.type];
320
+ if (ornXml) ornaments.push(`<${ornXml}/>`);
313
321
  }
314
322
  break;
315
323
 
@@ -325,13 +333,22 @@ const encodeNotations = (marks: Mark[], level: number): string => {
325
333
  otherNotations.push(`<tuplet type="${(mark as any).start ? 'start' : 'stop'}"/>`);
326
334
  break;
327
335
 
336
+ case 'glissando':
337
+ // Lilylet \glissando is start-only (the line auto-connects to the next
338
+ // note); emit just the start spanner so the decoder reads it back.
339
+ otherNotations.push('<glissando type="start" number="1"/>');
340
+ break;
341
+
328
342
  case 'fingering':
329
- // Fingering goes in technical
343
+ technical.push(`<fingering>${mark.finger}</fingering>`);
330
344
  break;
331
345
  }
332
346
  }
333
347
 
334
- if (articulations.length === 0 && ornaments.length === 0 && otherNotations.length === 0) {
348
+ if (
349
+ articulations.length === 0 && ornaments.length === 0 &&
350
+ technical.length === 0 && otherNotations.length === 0
351
+ ) {
335
352
  return '';
336
353
  }
337
354
 
@@ -357,6 +374,14 @@ const encodeNotations = (marks: Mark[], level: number): string => {
357
374
  xml += `${indent(level + 1)}</ornaments>\n`;
358
375
  }
359
376
 
377
+ if (technical.length > 0) {
378
+ xml += `${indent(level + 1)}<technical>\n`;
379
+ for (const tech of technical) {
380
+ xml += `${indent(level + 2)}${tech}\n`;
381
+ }
382
+ xml += `${indent(level + 1)}</technical>\n`;
383
+ }
384
+
360
385
  xml += `${indent(level)}</notations>\n`;
361
386
 
362
387
  return xml;
@@ -518,10 +543,19 @@ const encodeRestWithTuplet = (
518
543
  */
519
544
  const encodeDirection = (
520
545
  marks: Mark[],
521
- level: number
546
+ level: number,
547
+ staff: number = 0,
548
+ emitStaff: boolean = false
522
549
  ): string => {
523
550
  let xml = '';
524
551
 
552
+ // In a grand-staff part, a <direction> must name its <staff> or the decoder
553
+ // can't route the mark back to the right staff (it would attach to the current
554
+ // voice/staff and drift — observed on chopin dynamics and cross-measure pedals).
555
+ const staffTag = (emitStaff && staff > 0)
556
+ ? `${indent(level + 1)}<staff>${staff}</staff>\n`
557
+ : '';
558
+
525
559
  for (const mark of marks) {
526
560
  if (mark.markType === 'dynamic') {
527
561
  const dynXml = DYNAMIC_TO_XML[mark.type];
@@ -532,6 +566,7 @@ const encodeDirection = (
532
566
  xml += `${indent(level + 3)}<${dynXml}/>\n`;
533
567
  xml += `${indent(level + 2)}</dynamics>\n`;
534
568
  xml += `${indent(level + 1)}</direction-type>\n`;
569
+ xml += staffTag;
535
570
  xml += `${indent(level)}</direction>\n`;
536
571
  }
537
572
  } else if (mark.markType === 'hairpin') {
@@ -548,6 +583,7 @@ const encodeDirection = (
548
583
  xml += `${indent(level + 1)}<direction-type>\n`;
549
584
  xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
550
585
  xml += `${indent(level + 1)}</direction-type>\n`;
586
+ xml += staffTag;
551
587
  xml += `${indent(level)}</direction>\n`;
552
588
  }
553
589
  } else if (mark.markType === 'pedal') {
@@ -562,8 +598,28 @@ const encodeDirection = (
562
598
  xml += `${indent(level + 1)}<direction-type>\n`;
563
599
  xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
564
600
  xml += `${indent(level + 1)}</direction-type>\n`;
601
+ xml += staffTag;
565
602
  xml += `${indent(level)}</direction>\n`;
566
603
  }
604
+ } else if (mark.markType === 'markup') {
605
+ // Text direction → <words>. The decoder reads any non-tempo <words> back
606
+ // as a markup mark (text-as-markup convention).
607
+ const placement = mark.placement ? ` placement="${mark.placement}"` : '';
608
+ xml += `${indent(level)}<direction${placement}>\n`;
609
+ xml += `${indent(level + 1)}<direction-type>\n`;
610
+ xml += `${indent(level + 2)}<words>${escapeXml(mark.content)}</words>\n`;
611
+ xml += `${indent(level + 1)}</direction-type>\n`;
612
+ xml += staffTag;
613
+ xml += `${indent(level)}</direction>\n`;
614
+ } else if (mark.markType === 'navigation') {
615
+ // \coda / \segno glyph → <direction-type><coda|segno>.
616
+ const glyph = mark.type === NavigationMarkType.coda ? 'coda' : 'segno';
617
+ xml += `${indent(level)}<direction>\n`;
618
+ xml += `${indent(level + 1)}<direction-type>\n`;
619
+ xml += `${indent(level + 2)}<${glyph}/>\n`;
620
+ xml += `${indent(level + 1)}</direction-type>\n`;
621
+ xml += staffTag;
622
+ xml += `${indent(level)}</direction>\n`;
567
623
  }
568
624
  }
569
625
 
@@ -718,12 +774,14 @@ const encodeMeasure = (
718
774
  for (const event of voice.events) {
719
775
  switch (event.type) {
720
776
  case 'note': {
721
- // Check for direction marks (dynamics, hairpins, pedals)
777
+ // Check for direction marks (dynamics, hairpins, pedals, text, nav)
722
778
  const directionMarks = event.marks?.filter(m =>
723
- m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
779
+ m.markType === 'dynamic' || m.markType === 'hairpin' ||
780
+ m.markType === 'pedal' || m.markType === 'markup' ||
781
+ m.markType === 'navigation'
724
782
  ) || [];
725
783
  if (directionMarks.length > 0) {
726
- xml += encodeDirection(directionMarks, level + 1);
784
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
727
785
  }
728
786
 
729
787
  // Encode main note
@@ -731,11 +789,15 @@ const encodeMeasure = (
731
789
  const dur = calculateDuration(event.duration);
732
790
  voicePosition += dur;
733
791
 
734
- // Encode chord notes
792
+ // Encode chord notes. Marks live on the parent NoteEvent (the whole
793
+ // chord) and are emitted on the first note only — re-emitting them on
794
+ // each member would multiply them on decode (the decoder aggregates
795
+ // every member's marks back onto the one chord event).
735
796
  for (let i = 1; i < event.pitches.length; i++) {
736
797
  const chordEvent: NoteEvent = {
737
798
  ...event,
738
799
  pitches: [event.pitches[i]],
800
+ marks: undefined,
739
801
  };
740
802
  xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
741
803
  }
@@ -773,6 +835,17 @@ const encodeMeasure = (
773
835
  const isLast = ti === tupletEvents.length - 1;
774
836
 
775
837
  if (subEvent.type === 'note') {
838
+ // Direction marks (dynamics, hairpins, pedals, text, nav) — same
839
+ // as the plain-note case; without this they vanish inside tuplets.
840
+ const directionMarks = subEvent.marks?.filter(m =>
841
+ m.markType === 'dynamic' || m.markType === 'hairpin' ||
842
+ m.markType === 'pedal' || m.markType === 'markup' ||
843
+ m.markType === 'navigation'
844
+ ) || [];
845
+ if (directionMarks.length > 0) {
846
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
847
+ }
848
+
776
849
  // Add tuplet notation marks
777
850
  const tupletMarks: Mark[] = [];
778
851
  if (isFirst) tupletMarks.push({ markType: 'tuplet', start: true } as any);
@@ -59,10 +59,13 @@ export interface MusicXmlNotations {
59
59
  beams?: { type: 'begin' | 'continue' | 'end'; number: number }[];
60
60
  articulations?: string[]; // staccato, accent, tenuto, etc.
61
61
  ornaments?: string[]; // trill, turn, mordent, etc.
62
- fermata?: boolean;
62
+ fermata?: 'normal' | 'short'; // 'short' = angled shape (LilyPond \shortfermata)
63
63
  arpeggiate?: boolean;
64
64
  tremolo?: { type: 'single' | 'start' | 'stop'; value: number };
65
65
  tuplet?: { type: 'start' | 'stop'; number: number };
66
+ // Glissando / slide spanner: only the start matters for lilylet (LilyPond emits
67
+ // \glissando on the start note; the line auto-connects to the next note).
68
+ glissando?: boolean;
66
69
  }
67
70
 
68
71
  export interface MusicXmlNote {
@@ -121,6 +124,7 @@ export interface MusicXmlDirection {
121
124
  pedal?: MusicXmlPedal;
122
125
  metronome?: MusicXmlMetronome;
123
126
  words?: MusicXmlWords[];
127
+ rehearsal?: string; // <direction-type><rehearsal> text (e.g. "A", "段")
124
128
  octaveShift?: MusicXmlOctaveShift;
125
129
  coda?: boolean;
126
130
  segno?: boolean;
@@ -416,7 +416,7 @@ const DYNAMIC_MAP: Record<string, DynamicType> = {
416
416
  fz: DynamicType.sfz,
417
417
  sfp: DynamicType.sfz,
418
418
  sfpp: DynamicType.sfz,
419
- fp: DynamicType.f, // forte-piano, approximate
419
+ fp: DynamicType.fp, // forte-piano
420
420
  };
421
421
 
422
422
  export const convertDynamic = (name: string): DynamicType | undefined => {
@@ -166,6 +166,7 @@ const ORNAMENT_MAP: Record<string, string> = {
166
166
  fermata: '\\fermata',
167
167
  shortFermata: '\\shortfermata',
168
168
  arpeggio: '\\arpeggio',
169
+ breath: '\\breathe',
169
170
  };
170
171
 
171
172
 
@@ -301,6 +302,19 @@ const serializeMarks = (marks: Mark[]): string => {
301
302
  case 'fingering':
302
303
  parts.push('-' + mark.finger);
303
304
  break;
305
+ case 'markup': {
306
+ // Text direction attached to a note (e.g. ^\markup "cantabile").
307
+ // Placement: above → ^, below → _, unspecified → - (default).
308
+ const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
309
+ parts.push(prefix + '\\markup "' + escapeString(mark.content) + '"');
310
+ break;
311
+ }
312
+ case 'navigation':
313
+ parts.push(mark.type === 'coda' ? '\\coda' : '\\segno');
314
+ break;
315
+ case 'glissando':
316
+ parts.push('\\glissando');
317
+ break;
304
318
  }
305
319
  }
306
320
 
@@ -47,6 +47,7 @@ export enum OrnamentType {
47
47
  fermata = 'fermata',
48
48
  shortFermata = 'shortFermata',
49
49
  arpeggio = 'arpeggio',
50
+ breath = 'breath', // breath mark (\breathe) — MusicXML <breath-mark>
50
51
  }
51
52
 
52
53
  export enum DynamicType {
@@ -187,7 +188,15 @@ export interface MarkupMark {
187
188
  placement?: Placement;
188
189
  }
189
190
 
190
- export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark;
191
+ // Glissando / slide: a note-to-note slide line. Mirrors LilyPond's \glissando,
192
+ // which is emitted only on the START note (the line auto-connects to the next
193
+ // note), so this is a single mark — no start/stop pairing. MusicXML <glissando>
194
+ // and <slide> both decode to this.
195
+ export interface Glissando {
196
+ markType: 'glissando';
197
+ }
198
+
199
+ export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark | Glissando;
191
200
 
192
201
  // === Key Signature ===
193
202