@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
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
|
package/lib/lilypondEncoder.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/lilypondEncoder.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
559
|
-
const
|
|
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 <
|
|
563
|
-
const measure =
|
|
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 (
|
|
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 (!
|
|
575
|
-
|
|
622
|
+
if (!staffMap.has(staff)) {
|
|
623
|
+
staffMap.set(staff, []);
|
|
576
624
|
}
|
|
577
|
-
const staffMeasures =
|
|
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
|
|
593
|
-
|
|
594
|
-
const
|
|
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
|
-
|
|
604
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
+
"@k-l-lambda/lotus": "^1.0.5"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^20.11.20",
|