@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 +2 -0
- package/lib/lilypondEncoder.d.ts +5 -0
- package/lib/lilypondEncoder.js +113 -23
- package/lib/meiEncoder.js +2 -1
- package/package.json +2 -2
- package/source/lilylet/index.ts +3 -0
- package/source/lilylet/lilypondDecoder.ts +473 -80
- package/source/lilylet/lilypondEncoder.ts +129 -26
- package/source/lilylet/meiEncoder.ts +2 -1
- package/lib/lilypondDecoder.d.ts +0 -28
- package/lib/lilypondDecoder.js +0 -645
|
@@ -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
|
|
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
|
-
//
|
|
700
|
-
const
|
|
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:
|
|
756
|
+
let currentTimeSig: { numerator: number; denominator: number } | undefined;
|
|
704
757
|
|
|
705
|
-
for (let mi = 0; mi <
|
|
706
|
-
const measure =
|
|
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 (
|
|
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 (!
|
|
719
|
-
|
|
777
|
+
if (!staffMap.has(staff)) {
|
|
778
|
+
staffMap.set(staff, []);
|
|
720
779
|
}
|
|
721
|
-
const staffMeasures =
|
|
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
|
|
741
|
-
|
|
742
|
-
const
|
|
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
|
-
|
|
755
|
-
|
|
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(
|
|
815
|
+
voiceLines.push(`${indent} \\new Voice {\n${measureContents.join('\n')}\n${indent} }`);
|
|
758
816
|
}
|
|
759
817
|
|
|
760
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/lilypondDecoder.d.ts
DELETED
|
@@ -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, };
|