@k-l-lambda/lilylet 0.1.39 → 0.1.40

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.
@@ -149,6 +149,21 @@ const BARLINE_MAP: Record<string, string> = {
149
149
  };
150
150
 
151
151
 
152
+ // === Helper Functions ===
153
+
154
+ /**
155
+ * Generate a spacer rest that fills a measure based on time signature.
156
+ * Uses multiplication syntax: s{denominator}*{numerator}
157
+ * @param timeSig - Time signature { numerator, denominator }
158
+ * @returns LilyPond spacer rest string (e.g., "s4*3" for 3/4, "s8*6" for 6/8)
159
+ */
160
+ const getSpacerRest = (timeSig?: { numerator: number; denominator: number }): string => {
161
+ if (!timeSig) return 's1';
162
+ const { numerator, denominator } = timeSig;
163
+ return `s${denominator}*${numerator}`;
164
+ };
165
+
166
+
152
167
  // === Pitch Environment for Relative Mode ===
153
168
 
154
169
  interface PitchEnv {
@@ -571,9 +586,10 @@ const encodeBarlineEvent = (event: BarlineEvent): string => {
571
586
 
572
587
  /**
573
588
  * Encode a harmony event (chord symbol)
589
+ * Uses the lilylet extension: \chords "text" (parsed by modified lotus grammar)
574
590
  */
575
591
  const encodeHarmonyEvent = (event: HarmonyEvent): string => {
576
- return `^\\markup { ${event.text} }`;
592
+ return `\\chords "${event.text}"`;
577
593
  };
578
594
 
579
595
 
@@ -692,33 +708,76 @@ const encodeMetadata = (metadata: Metadata): string => {
692
708
 
693
709
  /**
694
710
  * Encode a complete LilyletDoc to LilyPond format
711
+ *
712
+ * Structure:
713
+ * - Multiple parts → outer <<>>
714
+ * - Part with multiple staves → GrandStaff
715
+ * - Part with single staff → standalone Staff
695
716
  */
696
717
  export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string => {
697
718
  const opts = { ...DEFAULT_OPTIONS, ...options };
698
719
 
699
- // Collect all voices across measures, grouped by staff
700
- const staffVoices: Map<number, string[][]> = new Map(); // staff -> measure -> voice content
720
+ // Filter out trailing empty measures (measures with no musical content)
721
+ const hasMusicContent = (measure: Measure): boolean => {
722
+ for (const part of measure.parts) {
723
+ for (const voice of part.voices) {
724
+ for (const event of voice.events) {
725
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
726
+ return true;
727
+ }
728
+ }
729
+ }
730
+ }
731
+ return false;
732
+ };
733
+
734
+ // Trim trailing empty measures
735
+ let measureCount = doc.measures.length;
736
+ while (measureCount > 0 && !hasMusicContent(doc.measures[measureCount - 1])) {
737
+ measureCount--;
738
+ }
739
+ const measures = doc.measures.slice(0, measureCount);
740
+
741
+ // Determine number of parts from the document
742
+ const partCount = Math.max(...measures.map(m => m.parts.length), 1);
743
+
744
+ // For each part, collect voices grouped by staff
745
+ // partVoices[partIndex][staff] = measureContents[][] (measure -> voice contents)
746
+ type StaffVoicesMap = Map<number, string[][]>;
747
+ const partVoices: StaffVoicesMap[] = [];
748
+ for (let pi = 0; pi < partCount; pi++) {
749
+ partVoices.push(new Map());
750
+ }
751
+
752
+ // Track time signature for each measure (for spacer rests)
753
+ const measureTimeSigs: Array<{ numerator: number; denominator: number } | undefined> = [];
701
754
 
702
755
  let currentKey: KeySignature | undefined;
703
- let currentTimeSig: any;
756
+ let currentTimeSig: { numerator: number; denominator: number } | undefined;
704
757
 
705
- for (let mi = 0; mi < doc.measures.length; mi++) {
706
- const measure = doc.measures[mi];
758
+ for (let mi = 0; mi < measures.length; mi++) {
759
+ const measure = measures[mi];
707
760
 
708
761
  // Update context from measure
709
762
  if (measure.key) currentKey = measure.key;
710
763
  if (measure.timeSig) currentTimeSig = measure.timeSig;
711
764
 
765
+ // Store time signature for this measure
766
+ measureTimeSigs[mi] = currentTimeSig;
767
+
712
768
  // Process each part
713
- for (const part of measure.parts) {
769
+ for (let pi = 0; pi < measure.parts.length; pi++) {
770
+ const part = measure.parts[pi];
771
+ const staffMap = partVoices[pi];
772
+
714
773
  for (let vi = 0; vi < part.voices.length; vi++) {
715
774
  const voice = part.voices[vi];
716
775
  const staff = voice.staff || 1;
717
776
 
718
- if (!staffVoices.has(staff)) {
719
- staffVoices.set(staff, []);
777
+ if (!staffMap.has(staff)) {
778
+ staffMap.set(staff, []);
720
779
  }
721
- const staffMeasures = staffVoices.get(staff)!;
780
+ const staffMeasures = staffMap.get(staff)!;
722
781
 
723
782
  // Ensure we have enough measure slots
724
783
  while (staffMeasures.length <= mi) {
@@ -737,13 +796,9 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
737
796
  }
738
797
  }
739
798
 
740
- // Build music content
741
- const staffCount = Math.max(...Array.from(staffVoices.keys()));
742
- const staffStrings: string[] = [];
743
-
744
- for (let si = 1; si <= staffCount; si++) {
745
- const measures = staffVoices.get(si) || [];
746
-
799
+ // Build a staff string (used for both GrandStaff children and standalone Staff)
800
+ // Staff ID format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1") for multi-part decoding
801
+ const buildStaffString = (measures: string[][], staffId: string, indent: string): string => {
747
802
  // Find max voices per measure for this staff
748
803
  const maxVoices = Math.max(...measures.map(m => m.length), 1);
749
804
 
@@ -751,16 +806,66 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
751
806
  const voiceLines: string[] = [];
752
807
  for (let vi = 0; vi < maxVoices; vi++) {
753
808
  const measureContents = measures.map((m, mi) => {
754
- const content = m[vi] || 's1'; // Space rest if no content
755
- return ` ${content} | % ${mi + 1}`;
809
+ // Use correct spacer rest based on time signature
810
+ const spacer = getSpacerRest(measureTimeSigs[mi]);
811
+ const content = m[vi] || spacer;
812
+ // Wrap each measure in its own \relative c' to reset pitch context
813
+ return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
756
814
  });
757
- voiceLines.push(` \\new Voice \\relative c' {\n${measureContents.join('\n')}\n }`);
815
+ voiceLines.push(`${indent} \\new Voice {\n${measureContents.join('\n')}\n${indent} }`);
758
816
  }
759
817
 
760
- staffStrings.push(` \\new Staff = "${si}" <<\n${voiceLines.join('\n')}\n >>`);
818
+ return `${indent}\\new Staff = "${staffId}" <<\n${voiceLines.join('\n')}\n${indent}>>`;
819
+ };
820
+
821
+ // Build music content for each part
822
+ const partStrings: string[] = [];
823
+
824
+ for (let pi = 0; pi < partCount; pi++) {
825
+ const staffMap = partVoices[pi];
826
+ const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
827
+
828
+ if (staffNums.length === 0) {
829
+ // Empty part, skip
830
+ continue;
831
+ }
832
+
833
+ const partIndex = pi + 1; // 1-based part index
834
+
835
+ if (staffNums.length === 1) {
836
+ // Single staff part → standalone Staff
837
+ const staffNum = staffNums[0];
838
+ const measures = staffMap.get(staffNum)!;
839
+ const staffId = `${partIndex}_${staffNum}`;
840
+ const staffStr = buildStaffString(measures, staffId, ' ');
841
+ partStrings.push(staffStr);
842
+ } else {
843
+ // Multiple staves → GrandStaff
844
+ const staffStrings: string[] = [];
845
+ for (const staffNum of staffNums) {
846
+ const measures = staffMap.get(staffNum)!;
847
+ const staffId = `${partIndex}_${staffNum}`;
848
+ const staffStr = buildStaffString(measures, staffId, ' ');
849
+ staffStrings.push(staffStr);
850
+ }
851
+ partStrings.push(` \\new GrandStaff <<\n${staffStrings.join('\n')}\n >>`);
852
+ }
761
853
  }
762
854
 
763
- const musicContent = staffStrings.join('\n');
855
+ const musicContent = partStrings.join('\n');
856
+
857
+ // Determine outer wrapper
858
+ // - Single part with single staff → just Staff (no outer <<>>)
859
+ // - Single part with multiple staves → GrandStaff (no extra outer <<>>)
860
+ // - Multiple parts → outer <<>>
861
+ let scoreContent: string;
862
+ if (partCount === 1 && partStrings.length === 1) {
863
+ // Single part - use as-is (already has Staff or GrandStaff)
864
+ scoreContent = musicContent;
865
+ } else {
866
+ // Multiple parts - wrap in <<>>
867
+ scoreContent = ` <<\n${musicContent}\n >>`;
868
+ }
764
869
 
765
870
  // Build header
766
871
  const headerContent = doc.metadata ? encodeMetadata(doc.metadata) : ' tagline = ##f';
@@ -769,7 +874,7 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
769
874
  const paperWidth = typeof opts.paper?.width === 'number' ? `${opts.paper.width}\\mm` : opts.paper?.width || '210\\mm';
770
875
  const paperHeight = typeof opts.paper?.height === 'number' ? `${opts.paper.height}\\mm` : opts.paper?.height || '297\\mm';
771
876
 
772
- const lyDoc = `\\version "2.24.0"
877
+ const lyDoc = `\\version "2.22.0"
773
878
 
774
879
  \\language "english"
775
880
 
@@ -794,9 +899,7 @@ ${headerContent}
794
899
  }
795
900
 
796
901
  \\score {
797
- \\new GrandStaff <<
798
- ${musicContent}
799
- >>
902
+ ${scoreContent}
800
903
 
801
904
  \\layout { }${opts.withMIDI ? '\n \\midi { }' : ''}
802
905
  }
@@ -60,7 +60,8 @@ const keyToFifths = (key?: { pitch: string; accidental?: Accidental; mode: strin
60
60
 
61
61
  if (key.mode === 'minor') fifths -= 3;
62
62
 
63
- return fifths;
63
+ // Clamp to valid range [-7, 7] since standard notation doesn't support more than 7 sharps/flats
64
+ return Math.max(-7, Math.min(7, fifths));
64
65
  };
65
66
 
66
67
 
@@ -1,28 +0,0 @@
1
- /**
2
- * LilyPond to Lilylet Decoder
3
- *
4
- * Converts LilyPond notation files to Lilylet document format using the lotus parser.
5
- * This module is browser-compatible - it uses pre-compiled parser from lotus.
6
- */
7
- import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
8
- import { LilyletDoc, Event, Fraction } from "./types";
9
- interface ParsedMeasure {
10
- key: number | null;
11
- timeSig: Fraction | null;
12
- voices: ParsedVoice[];
13
- partial: boolean;
14
- }
15
- interface ParsedVoice {
16
- staff: number;
17
- events: Event[];
18
- }
19
- declare const parseLilyDocument: (lilyDocument: lilyParser.LilyDocument) => ParsedMeasure[];
20
- /**
21
- * Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
22
- */
23
- declare const decode: (lilypondSource: string) => LilyletDoc;
24
- /**
25
- * Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
26
- */
27
- declare const decodeFromDocument: (lilyDocument: lilyParser.LilyDocument) => LilyletDoc;
28
- export { decode, decodeFromDocument, parseLilyDocument, };