@k-l-lambda/lilylet 0.1.49 → 0.1.51

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.
Files changed (71) hide show
  1. package/lib/abc/abc.d.ts +102 -0
  2. package/lib/abc/abc.js +25 -0
  3. package/lib/abc/grammar.jison.js +1203 -0
  4. package/lib/abc/parser.d.ts +3 -0
  5. package/lib/abc/parser.js +6 -0
  6. package/lib/abcDecoder.d.ts +1 -0
  7. package/lib/abcDecoder.js +1 -0
  8. package/lib/grammar.jison.js +1 -1303
  9. package/lib/index.d.ts +1 -8
  10. package/lib/index.js +1 -10
  11. package/lib/lilylet/abcDecoder.d.ts +25 -0
  12. package/lib/lilylet/abcDecoder.js +1007 -0
  13. package/lib/lilylet/grammar.jison.js +1308 -0
  14. package/lib/lilylet/index.d.ts +10 -0
  15. package/lib/lilylet/index.js +10 -0
  16. package/lib/lilylet/lilypondDecoder.d.ts +29 -0
  17. package/lib/lilylet/lilypondDecoder.js +1053 -0
  18. package/lib/lilylet/lilypondEncoder.d.ts +34 -0
  19. package/lib/lilylet/lilypondEncoder.js +759 -0
  20. package/lib/lilylet/meiEncoder.d.ts +8 -0
  21. package/lib/lilylet/meiEncoder.js +1813 -0
  22. package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
  23. package/lib/lilylet/musicXmlDecoder.js +1195 -0
  24. package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
  25. package/lib/lilylet/musicXmlEncoder.js +701 -0
  26. package/lib/lilylet/musicXmlTypes.d.ts +199 -0
  27. package/lib/lilylet/musicXmlTypes.js +7 -0
  28. package/lib/lilylet/musicXmlUtils.d.ts +92 -0
  29. package/lib/lilylet/musicXmlUtils.js +469 -0
  30. package/lib/lilylet/parser.d.ts +3 -0
  31. package/lib/lilylet/parser.js +151 -0
  32. package/lib/lilylet/serializer.d.ts +11 -0
  33. package/lib/lilylet/serializer.js +702 -0
  34. package/lib/lilylet/types.d.ts +245 -0
  35. package/lib/lilylet/types.js +99 -0
  36. package/lib/lilypondDecoder.d.ts +1 -29
  37. package/lib/lilypondDecoder.js +1 -1006
  38. package/lib/lilypondEncoder.d.ts +1 -34
  39. package/lib/lilypondEncoder.js +1 -759
  40. package/lib/meiEncoder.d.ts +1 -8
  41. package/lib/meiEncoder.js +1 -1545
  42. package/lib/musicXmlDecoder.d.ts +1 -20
  43. package/lib/musicXmlDecoder.js +1 -1151
  44. package/lib/musicXmlEncoder.d.ts +1 -15
  45. package/lib/musicXmlEncoder.js +1 -666
  46. package/lib/musicXmlTypes.d.ts +1 -199
  47. package/lib/musicXmlTypes.js +1 -7
  48. package/lib/musicXmlUtils.d.ts +1 -81
  49. package/lib/musicXmlUtils.js +1 -435
  50. package/lib/parser.d.ts +1 -3
  51. package/lib/parser.js +1 -151
  52. package/lib/serializer.d.ts +1 -11
  53. package/lib/serializer.js +1 -650
  54. package/lib/types.d.ts +1 -244
  55. package/lib/types.js +1 -99
  56. package/package.json +2 -1
  57. package/source/abc/abc.jison +692 -0
  58. package/source/abc/abc.ts +176 -0
  59. package/source/abc/grammar.jison.js +1203 -0
  60. package/source/abc/parser.ts +12 -0
  61. package/source/lilylet/abcDecoder.ts +1121 -0
  62. package/source/lilylet/grammar.jison.js +195 -190
  63. package/source/lilylet/index.ts +4 -3
  64. package/source/lilylet/lilylet.jison +10 -3
  65. package/source/lilylet/lilypondDecoder.ts +91 -41
  66. package/source/lilylet/meiEncoder.ts +284 -0
  67. package/source/lilylet/musicXmlDecoder.ts +74 -27
  68. package/source/lilylet/musicXmlEncoder.ts +201 -146
  69. package/source/lilylet/musicXmlUtils.ts +46 -4
  70. package/source/lilylet/serializer.ts +75 -21
  71. package/source/lilylet/types.ts +1 -0
@@ -158,10 +158,13 @@ export const convertPitch = (
158
158
  };
159
159
  };
160
160
 
161
- // ============ Duration Conversion ============
161
+ // ============ Duration Constants & Mappings ============
162
+
163
+ // Standard divisions per quarter note (shared by encoder/decoder)
164
+ export const DIVISIONS = 4;
162
165
 
163
166
  // MusicXML note type to division (1=whole, 2=half, 4=quarter, etc.)
164
- const TYPE_TO_DIVISION: Record<string, number> = {
167
+ export const TYPE_TO_DIVISION: Record<string, number> = {
165
168
  maxima: 0.125,
166
169
  long: 0.25,
167
170
  breve: 0.5,
@@ -178,6 +181,43 @@ const TYPE_TO_DIVISION: Record<string, number> = {
178
181
  '1024th': 1024,
179
182
  };
180
183
 
184
+ // Division to MusicXML note type (inverse of TYPE_TO_DIVISION)
185
+ export const DIVISION_TO_TYPE: Record<number, string> = Object.fromEntries(
186
+ Object.entries(TYPE_TO_DIVISION).map(([type, div]) => [div, type])
187
+ );
188
+
189
+ /**
190
+ * Calculate duration in MusicXML divisions.
191
+ * Shared by encoder (with DIVISIONS=4) and potentially decoder.
192
+ *
193
+ * Duration.tuplet is in Lilylet ratio semantics:
194
+ * \times 2/3 → {numerator:2, denominator:3} → multiply by 2/3
195
+ */
196
+ export const calculateDuration = (duration: Duration, divisions: number = DIVISIONS): number => {
197
+ // Base duration: divisions * (4 / division)
198
+ // e.g., quarter (4) = divisions * 1
199
+ // half (2) = divisions * 2
200
+ // eighth (8) = divisions * 0.5
201
+ let dur = divisions * (4 / duration.division);
202
+
203
+ // Apply dots
204
+ if (duration.dots) {
205
+ let dotValue = dur / 2;
206
+ for (let i = 0; i < duration.dots; i++) {
207
+ dur += dotValue;
208
+ dotValue /= 2;
209
+ }
210
+ }
211
+
212
+ // Apply tuplet ratio: Lilylet ratio num/den means multiply by num/den
213
+ // e.g., \times 2/3 means each note's actual duration = written * 2/3
214
+ if (duration.tuplet) {
215
+ dur = dur * duration.tuplet.numerator / duration.tuplet.denominator;
216
+ }
217
+
218
+ return Math.round(dur);
219
+ };
220
+
181
221
  /**
182
222
  * Convert MusicXML duration to Lilylet Duration
183
223
  *
@@ -218,9 +258,11 @@ export const convertDuration = (
218
258
  };
219
259
 
220
260
  if (timeModification) {
261
+ // Store as Lilylet ratio: normalNotes/actualNotes
262
+ // MusicXML actual=3, normal=2 (triplet) → Lilylet ratio {num:2, den:3}
221
263
  result.tuplet = {
222
- numerator: timeModification.actualNotes,
223
- denominator: timeModification.normalNotes,
264
+ numerator: timeModification.normalNotes,
265
+ denominator: timeModification.actualNotes,
224
266
  };
225
267
  }
226
268
 
@@ -31,6 +31,7 @@ import {
31
31
  PedalType,
32
32
  Tempo,
33
33
  Placement,
34
+ Metadata,
34
35
  } from "./types";
35
36
 
36
37
 
@@ -259,6 +260,9 @@ const serializeMarks = (marks: Mark[]): string => {
259
260
  if (pedalStr) parts.push(pedalStr);
260
261
  break;
261
262
  }
263
+ case 'fingering':
264
+ parts.push('-' + mark.finger);
265
+ break;
262
266
  }
263
267
  }
264
268
 
@@ -584,14 +588,16 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
584
588
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
585
589
  // If isGrandStaff is true, always output \staff command for clarity
586
590
  // measureContext provides key/time for first voice
587
- // staffClef is the clef for this voice's staff (tracked across measures)
591
+ // allStaffClefs is the clef map for all staves (tracked across measures)
592
+ // emittedClefs tracks which clefs have already been output (avoids duplicates)
588
593
  const serializeVoice = (
589
594
  voice: Voice,
590
595
  currentStaff: number,
591
596
  isGrandStaff: boolean = false,
592
597
  measureContext?: MeasureContext,
593
598
  isFirstVoice: boolean = false,
594
- staffClef?: Clef
599
+ allStaffClefs?: Record<number, Clef>,
600
+ emittedClefs?: Record<number, Clef>
595
601
  ): { str: string; newStaff: number } => {
596
602
  const parts: string[] = [];
597
603
  let prevDuration: Duration | undefined;
@@ -625,25 +631,63 @@ const serializeVoice = (
625
631
  }
626
632
  }
627
633
 
628
- // Output clef for every voice (use staff clef tracked across measures, or find from voice events)
629
- const voiceClef = staffClef || findVoiceClef(voice);
630
- if (voiceClef) {
634
+ // Output clef only if not yet emitted or changed for this staff
635
+ const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
636
+ const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
637
+ if (voiceClef && !clefAlreadyEmitted) {
631
638
  parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
639
+ if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
632
640
  }
641
+ // Skip redundant clef context events if this staff's clef is already established
642
+ const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
633
643
 
634
- // Track if we've already output the clef to avoid duplication
635
- let clefOutputted = !!voiceClef;
644
+ let activeStaff = voice.staff;
645
+ let activeStemDir: StemDirection | undefined;
636
646
 
637
647
  for (const event of voice.events) {
638
- // Skip clef context events if we've already output the clef at the beginning
639
- if (clefOutputted && event.type === 'context') {
648
+ if (event.type === 'context') {
640
649
  const ctx = event as ContextChange;
641
- if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
642
- // This is a clef-only context event, skip it
650
+ // Skip context events that belong to a different staff (cross-staff clef/ottava)
651
+ if (ctx.staff && ctx.staff !== voice.staff) {
652
+ continue;
653
+ }
654
+ // Skip clef-only context events if clef already established for this staff
655
+ if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
643
656
  continue;
644
657
  }
645
658
  }
646
659
 
660
+ if (event.type === 'note') {
661
+ const noteEvt = event as NoteEvent;
662
+
663
+ // Cross-staff: emit \staff when note's effective staff differs from active
664
+ const effectiveStaff = noteEvt.staff || voice.staff;
665
+ if (effectiveStaff !== activeStaff) {
666
+ activeStaff = effectiveStaff;
667
+ parts.push('\\staff "' + activeStaff + '"');
668
+ // Emit the target staff's clef if it differs from what was last emitted for this staff
669
+ const targetClef = allStaffClefs?.[activeStaff];
670
+ if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
671
+ parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
672
+ if (emittedClefs) emittedClefs[activeStaff] = targetClef;
673
+ }
674
+ }
675
+
676
+ // Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
677
+ const stemDir = noteEvt.stemDirection;
678
+ if (stemDir !== activeStemDir) {
679
+ if (stemDir === StemDirection.up) {
680
+ parts.push('\\stemUp');
681
+ } else if (stemDir === StemDirection.down) {
682
+ parts.push('\\stemDown');
683
+ } else if (activeStemDir) {
684
+ // Was set, now undefined → reset to neutral
685
+ parts.push('\\stemNeutral');
686
+ }
687
+ activeStemDir = stemDir;
688
+ }
689
+ }
690
+
647
691
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
648
692
  pitchEnv = newEnv;
649
693
 
@@ -656,6 +700,9 @@ const serializeVoice = (
656
700
  prevDuration = (event as NoteEvent).duration;
657
701
  } else if (event.type === 'rest') {
658
702
  prevDuration = (event as RestEvent).duration;
703
+ } else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
704
+ const ctx = event as ContextChange;
705
+ emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
659
706
  }
660
707
  }
661
708
 
@@ -671,7 +718,8 @@ const serializePart = (
671
718
  isGrandStaff: boolean = false,
672
719
  measureContext?: MeasureContext,
673
720
  isFirstPart: boolean = false,
674
- clefsByStaff?: Record<number, Clef>
721
+ clefsByStaff?: Record<number, Clef>,
722
+ emittedClefs?: Record<number, Clef>
675
723
  ): { str: string; newStaff: number } => {
676
724
  if (part.voices.length === 0) {
677
725
  return { str: '', newStaff: currentStaff };
@@ -683,10 +731,8 @@ const serializePart = (
683
731
  for (let i = 0; i < part.voices.length; i++) {
684
732
  const voice = part.voices[i];
685
733
  // Pass measureContext to all voices, isFirstVoice for key/time
686
- // Pass staff clef from clefsByStaff map
687
734
  const isFirstVoice = isFirstPart && i === 0;
688
- const staffClef = clefsByStaff?.[voice.staff];
689
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
735
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
690
736
  voiceStrs.push(str);
691
737
  staff = newStaff;
692
738
  }
@@ -705,7 +751,8 @@ const serializeMeasure = (
705
751
  isGrandStaff: boolean = false,
706
752
  currentKey?: KeySignature,
707
753
  currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
708
- staffClefs?: Record<number, Clef>
754
+ staffClefs?: Record<number, Clef>,
755
+ emittedClefs?: Record<number, Clef>
709
756
  ): { str: string; newStaff: number } => {
710
757
  const parts: string[] = [];
711
758
 
@@ -723,7 +770,7 @@ const serializeMeasure = (
723
770
  // Parts
724
771
  let staff = currentStaff;
725
772
  if (measure.parts.length === 1) {
726
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
773
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
727
774
  if (partStr) {
728
775
  parts.push(partStr);
729
776
  }
@@ -734,7 +781,7 @@ const serializeMeasure = (
734
781
  for (let i = 0; i < measure.parts.length; i++) {
735
782
  const part = measure.parts[i];
736
783
  // Pass measureContext to all parts, isFirstPart to first part only
737
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
784
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
738
785
  if (str) {
739
786
  partStrs.push(str);
740
787
  }
@@ -753,7 +800,7 @@ const escapeString = (str: string): string => {
753
800
  };
754
801
 
755
802
  // Serialize metadata
756
- const serializeMetadata = (metadata: any): string => {
803
+ const serializeMetadata = (metadata: Metadata): string => {
757
804
  const lines: string[] = [];
758
805
 
759
806
  if (metadata.title) {
@@ -771,6 +818,9 @@ const serializeMetadata = (metadata: any): string => {
771
818
  if (metadata.lyricist) {
772
819
  lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
773
820
  }
821
+ if (metadata.autoBeam) {
822
+ lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
823
+ }
774
824
 
775
825
  return lines.join('\n');
776
826
  };
@@ -806,6 +856,7 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
806
856
  let currentKey: KeySignature | undefined;
807
857
  let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
808
858
  const staffClefs: Record<number, Clef> = {}; // Track clef per staff
859
+ const emittedClefs: Record<number, Clef> = {}; // Track which clefs have been output
809
860
 
810
861
  for (let i = 0; i < doc.measures.length; i++) {
811
862
  const measure = doc.measures[i];
@@ -822,13 +873,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
822
873
  for (const voice of part.voices) {
823
874
  for (const event of voice.events) {
824
875
  if (event.type === 'context' && (event as ContextChange).clef) {
825
- staffClefs[voice.staff] = (event as ContextChange).clef!;
876
+ const ctx = event as ContextChange;
877
+ // Use the event's staff if specified (cross-staff), otherwise the voice's staff
878
+ const clefStaff = ctx.staff || voice.staff;
879
+ staffClefs[clefStaff] = ctx.clef!;
826
880
  }
827
881
  }
828
882
  }
829
883
  }
830
884
 
831
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
885
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
832
886
  // Always include measure, even if empty (use space rest for empty measures)
833
887
  measureStrs.push(measureStr || 's1');
834
888
  currentStaff = newStaff;
@@ -286,6 +286,7 @@ export interface Metadata {
286
286
  opus?: string;
287
287
  instrument?: string;
288
288
  genre?: string;
289
+ autoBeam?: 'auto' | 'on' | 'off';
289
290
  }
290
291
 
291
292
  // Part within a measure: can be a single staff or grand staff (multiple staves)