@k-l-lambda/lilylet 0.1.39 → 0.1.44

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/index.js CHANGED
@@ -6,3 +6,5 @@ import * as musicXmlDecoder from "./musicXmlDecoder.js";
6
6
  import * as lilypondEncoder from "./lilypondEncoder.js";
7
7
  import * as musicXmlEncoder from "./musicXmlEncoder.js";
8
8
  export { meiEncoder, musicXmlDecoder, lilypondEncoder, musicXmlEncoder, };
9
+ // Note: lilypondDecoder is optional and requires @k-l-lambda/lotus
10
+ // Import it directly from '@k-l-lambda/lilylet/lib/lilypondDecoder.js' if needed
@@ -16,6 +16,11 @@ interface RenderOptions {
16
16
  }
17
17
  /**
18
18
  * Encode a complete LilyletDoc to LilyPond format
19
+ *
20
+ * Structure:
21
+ * - Multiple parts → outer <<>>
22
+ * - Part with multiple staves → GrandStaff
23
+ * - Part with single staff → standalone Staff
19
24
  */
20
25
  export declare const encode: (doc: LilyletDoc, options?: RenderOptions) => string;
21
26
  /**
@@ -102,6 +102,19 @@ const BARLINE_MAP = {
102
102
  ":..:": ":..:",
103
103
  ":..:|": ":..:|",
104
104
  };
105
+ // === Helper Functions ===
106
+ /**
107
+ * Generate a spacer rest that fills a measure based on time signature.
108
+ * Uses multiplication syntax: s{denominator}*{numerator}
109
+ * @param timeSig - Time signature { numerator, denominator }
110
+ * @returns LilyPond spacer rest string (e.g., "s4*3" for 3/4, "s8*6" for 6/8)
111
+ */
112
+ const getSpacerRest = (timeSig) => {
113
+ if (!timeSig)
114
+ return 's1';
115
+ const { numerator, denominator } = timeSig;
116
+ return `s${denominator}*${numerator}`;
117
+ };
105
118
  /**
106
119
  * Calculate the octave markers needed to serialize a pitch in relative mode.
107
120
  */
@@ -448,9 +461,10 @@ const encodeBarlineEvent = (event) => {
448
461
  };
449
462
  /**
450
463
  * Encode a harmony event (chord symbol)
464
+ * Uses the lilylet extension: \chords "text" (parsed by modified lotus grammar)
451
465
  */
452
466
  const encodeHarmonyEvent = (event) => {
453
- return `^\\markup { ${event.text} }`;
467
+ return `\\chords "${event.text}"`;
454
468
  };
455
469
  /**
456
470
  * Encode a markup event
@@ -552,29 +566,63 @@ const encodeMetadata = (metadata) => {
552
566
  };
553
567
  /**
554
568
  * Encode a complete LilyletDoc to LilyPond format
569
+ *
570
+ * Structure:
571
+ * - Multiple parts → outer <<>>
572
+ * - Part with multiple staves → GrandStaff
573
+ * - Part with single staff → standalone Staff
555
574
  */
556
575
  export const encode = (doc, options = {}) => {
557
576
  const opts = { ...DEFAULT_OPTIONS, ...options };
558
- // Collect all voices across measures, grouped by staff
559
- const staffVoices = new Map(); // staff -> measure -> voice content
577
+ // Filter out trailing empty measures (measures with no musical content)
578
+ const hasMusicContent = (measure) => {
579
+ for (const part of measure.parts) {
580
+ for (const voice of part.voices) {
581
+ for (const event of voice.events) {
582
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
583
+ return true;
584
+ }
585
+ }
586
+ }
587
+ }
588
+ return false;
589
+ };
590
+ // Trim trailing empty measures
591
+ let measureCount = doc.measures.length;
592
+ while (measureCount > 0 && !hasMusicContent(doc.measures[measureCount - 1])) {
593
+ measureCount--;
594
+ }
595
+ const measures = doc.measures.slice(0, measureCount);
596
+ // Determine number of parts from the document
597
+ const partCount = Math.max(...measures.map(m => m.parts.length), 1);
598
+ const partVoices = [];
599
+ for (let pi = 0; pi < partCount; pi++) {
600
+ partVoices.push(new Map());
601
+ }
602
+ // Track time signature for each measure (for spacer rests)
603
+ const measureTimeSigs = [];
560
604
  let currentKey;
561
605
  let currentTimeSig;
562
- for (let mi = 0; mi < doc.measures.length; mi++) {
563
- const measure = doc.measures[mi];
606
+ for (let mi = 0; mi < measures.length; mi++) {
607
+ const measure = measures[mi];
564
608
  // Update context from measure
565
609
  if (measure.key)
566
610
  currentKey = measure.key;
567
611
  if (measure.timeSig)
568
612
  currentTimeSig = measure.timeSig;
613
+ // Store time signature for this measure
614
+ measureTimeSigs[mi] = currentTimeSig;
569
615
  // Process each part
570
- for (const part of measure.parts) {
616
+ for (let pi = 0; pi < measure.parts.length; pi++) {
617
+ const part = measure.parts[pi];
618
+ const staffMap = partVoices[pi];
571
619
  for (let vi = 0; vi < part.voices.length; vi++) {
572
620
  const voice = part.voices[vi];
573
621
  const staff = voice.staff || 1;
574
- if (!staffVoices.has(staff)) {
575
- staffVoices.set(staff, []);
622
+ if (!staffMap.has(staff)) {
623
+ staffMap.set(staff, []);
576
624
  }
577
- const staffMeasures = staffVoices.get(staff);
625
+ const staffMeasures = staffMap.get(staff);
578
626
  // Ensure we have enough measure slots
579
627
  while (staffMeasures.length <= mi) {
580
628
  staffMeasures.push([]);
@@ -589,31 +637,75 @@ export const encode = (doc, options = {}) => {
589
637
  }
590
638
  }
591
639
  }
592
- // Build music content
593
- const staffCount = Math.max(...Array.from(staffVoices.keys()));
594
- const staffStrings = [];
595
- for (let si = 1; si <= staffCount; si++) {
596
- const measures = staffVoices.get(si) || [];
640
+ // Build a staff string (used for both GrandStaff children and standalone Staff)
641
+ // Staff ID format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1") for multi-part decoding
642
+ const buildStaffString = (measures, staffId, indent) => {
597
643
  // Find max voices per measure for this staff
598
644
  const maxVoices = Math.max(...measures.map(m => m.length), 1);
599
645
  // Build voice lines
600
646
  const voiceLines = [];
601
647
  for (let vi = 0; vi < maxVoices; vi++) {
602
648
  const measureContents = measures.map((m, mi) => {
603
- const content = m[vi] || 's1'; // Space rest if no content
604
- return ` ${content} | % ${mi + 1}`;
649
+ // Use correct spacer rest based on time signature
650
+ const spacer = getSpacerRest(measureTimeSigs[mi]);
651
+ const content = m[vi] || spacer;
652
+ // Wrap each measure in its own \relative c' to reset pitch context
653
+ return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
605
654
  });
606
- voiceLines.push(` \\new Voice \\relative c' {\n${measureContents.join('\n')}\n }`);
655
+ voiceLines.push(`${indent} \\new Voice {\n${measureContents.join('\n')}\n${indent} }`);
656
+ }
657
+ return `${indent}\\new Staff = "${staffId}" <<\n${voiceLines.join('\n')}\n${indent}>>`;
658
+ };
659
+ // Build music content for each part
660
+ const partStrings = [];
661
+ for (let pi = 0; pi < partCount; pi++) {
662
+ const staffMap = partVoices[pi];
663
+ const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
664
+ if (staffNums.length === 0) {
665
+ // Empty part, skip
666
+ continue;
607
667
  }
608
- staffStrings.push(` \\new Staff = "${si}" <<\n${voiceLines.join('\n')}\n >>`);
668
+ const partIndex = pi + 1; // 1-based part index
669
+ if (staffNums.length === 1) {
670
+ // Single staff part → standalone Staff
671
+ const staffNum = staffNums[0];
672
+ const measures = staffMap.get(staffNum);
673
+ const staffId = `${partIndex}_${staffNum}`;
674
+ const staffStr = buildStaffString(measures, staffId, ' ');
675
+ partStrings.push(staffStr);
676
+ }
677
+ else {
678
+ // Multiple staves → GrandStaff
679
+ const staffStrings = [];
680
+ for (const staffNum of staffNums) {
681
+ const measures = staffMap.get(staffNum);
682
+ const staffId = `${partIndex}_${staffNum}`;
683
+ const staffStr = buildStaffString(measures, staffId, ' ');
684
+ staffStrings.push(staffStr);
685
+ }
686
+ partStrings.push(` \\new GrandStaff <<\n${staffStrings.join('\n')}\n >>`);
687
+ }
688
+ }
689
+ const musicContent = partStrings.join('\n');
690
+ // Determine outer wrapper
691
+ // - Single part with single staff → just Staff (no outer <<>>)
692
+ // - Single part with multiple staves → GrandStaff (no extra outer <<>>)
693
+ // - Multiple parts → outer <<>>
694
+ let scoreContent;
695
+ if (partCount === 1 && partStrings.length === 1) {
696
+ // Single part - use as-is (already has Staff or GrandStaff)
697
+ scoreContent = musicContent;
698
+ }
699
+ else {
700
+ // Multiple parts - wrap in <<>>
701
+ scoreContent = ` <<\n${musicContent}\n >>`;
609
702
  }
610
- const musicContent = staffStrings.join('\n');
611
703
  // Build header
612
704
  const headerContent = doc.metadata ? encodeMetadata(doc.metadata) : ' tagline = ##f';
613
705
  // Build document
614
706
  const paperWidth = typeof opts.paper?.width === 'number' ? `${opts.paper.width}\\mm` : opts.paper?.width || '210\\mm';
615
707
  const paperHeight = typeof opts.paper?.height === 'number' ? `${opts.paper.height}\\mm` : opts.paper?.height || '297\\mm';
616
- const lyDoc = `\\version "2.24.0"
708
+ const lyDoc = `\\version "2.22.0"
617
709
 
618
710
  \\language "english"
619
711
 
@@ -638,9 +730,7 @@ ${headerContent}
638
730
  }
639
731
 
640
732
  \\score {
641
- \\new GrandStaff <<
642
- ${musicContent}
643
- >>
733
+ ${scoreContent}
644
734
 
645
735
  \\layout { }${opts.withMIDI ? '\n \\midi { }' : ''}
646
736
  }
package/lib/meiEncoder.js CHANGED
@@ -32,7 +32,8 @@ const keyToFifths = (key) => {
32
32
  fifths -= 7;
33
33
  if (key.mode === 'minor')
34
34
  fifths -= 3;
35
- return fifths;
35
+ // Clamp to valid range [-7, 7] since standard notation doesn't support more than 7 sharps/flats
36
+ return Math.max(-7, Math.min(7, fifths));
36
37
  };
37
38
  const CLEF_SHAPES = {
38
39
  treble: { shape: "G", line: 2 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.39",
3
+ "version": "0.1.44",
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",
@@ -37,7 +37,7 @@
37
37
  "license": "ISC",
38
38
  "homepage": "https://github.com/k-l-lambda/lilylet#readme",
39
39
  "optionalDependencies": {
40
- "@k-l-lambda/lotus": "^1.0.3"
40
+ "@k-l-lambda/lotus": "^1.0.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^20.11.20",
@@ -14,3 +14,6 @@ export {
14
14
  lilypondEncoder,
15
15
  musicXmlEncoder,
16
16
  };
17
+
18
+ // Note: lilypondDecoder is optional and requires @k-l-lambda/lotus
19
+ // Import it directly from '@k-l-lambda/lilylet/lib/lilypondDecoder.js' if needed