@k-l-lambda/lilylet 0.1.38 → 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.
- 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/lilypondDecoder.ts +473 -80
- package/source/lilylet/lilypondEncoder.ts +129 -26
- package/source/lilylet/meiEncoder.ts +2 -1
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.40",
|
|
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",
|
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
RestEvent,
|
|
21
21
|
ContextChange,
|
|
22
22
|
MarkupEvent,
|
|
23
|
+
HarmonyEvent,
|
|
24
|
+
TupletEvent,
|
|
25
|
+
TremoloEvent,
|
|
23
26
|
Pitch,
|
|
24
27
|
Duration,
|
|
25
28
|
Mark,
|
|
@@ -206,6 +209,7 @@ interface ParsedMeasure {
|
|
|
206
209
|
|
|
207
210
|
interface ParsedVoice {
|
|
208
211
|
staff: number;
|
|
212
|
+
partIndex: number; // 1-based part index (from staff ID format "partIndex_staffIndex")
|
|
209
213
|
events: Event[];
|
|
210
214
|
}
|
|
211
215
|
|
|
@@ -237,25 +241,152 @@ const convertDuration = (duration: any): Duration => {
|
|
|
237
241
|
};
|
|
238
242
|
|
|
239
243
|
|
|
240
|
-
//
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
// Parse raw pitch string (e.g., "c'", "fis''", "bes,") to Pitch
|
|
245
|
+
const parseRawPitch = (pitchStr: string): Pitch | undefined => {
|
|
246
|
+
if (!pitchStr) return undefined;
|
|
247
|
+
|
|
248
|
+
// Match: base note (a-g), optional accidentals (is/es/isis/eses), optional octave marks ('/, or ,)
|
|
249
|
+
const match = pitchStr.match(/^([a-g])(isis|eses|is|es)?([',]*)$/);
|
|
250
|
+
if (!match) return undefined;
|
|
251
|
+
|
|
252
|
+
const [, note, accidental, octaveMarks] = match;
|
|
253
|
+
|
|
254
|
+
// Map note to phonet
|
|
255
|
+
const phonetMap: Record<string, Phonet> = {
|
|
256
|
+
c: Phonet.c, d: Phonet.d, e: Phonet.e, f: Phonet.f,
|
|
257
|
+
g: Phonet.g, a: Phonet.a, b: Phonet.b,
|
|
258
|
+
};
|
|
259
|
+
const phonet = phonetMap[note];
|
|
260
|
+
if (!phonet) return undefined;
|
|
261
|
+
|
|
262
|
+
// Map accidental
|
|
263
|
+
const accidentalMap: Record<string, Accidental> = {
|
|
264
|
+
is: Accidental.sharp,
|
|
265
|
+
es: Accidental.flat,
|
|
266
|
+
isis: Accidental.doubleSharp,
|
|
267
|
+
eses: Accidental.doubleFlat,
|
|
268
|
+
};
|
|
269
|
+
const acc = accidental ? accidentalMap[accidental] : undefined;
|
|
270
|
+
|
|
271
|
+
// Calculate octave from marks (default octave 0 = C4)
|
|
272
|
+
let octave = 0;
|
|
273
|
+
for (const mark of octaveMarks || '') {
|
|
274
|
+
if (mark === "'") octave++;
|
|
275
|
+
else if (mark === ",") octave--;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { phonet, accidental: acc, octave };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
// Parse raw duration object from tuplet body
|
|
283
|
+
const parseRawDuration = (duration: any): Duration | undefined => {
|
|
284
|
+
if (!duration) return undefined;
|
|
285
|
+
const number = parseInt(duration.number, 10);
|
|
286
|
+
if (isNaN(number)) return undefined;
|
|
287
|
+
return {
|
|
288
|
+
division: number,
|
|
289
|
+
dots: duration.dots || 0,
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
// Convert raw Chord from tuplet body to NoteEvent
|
|
295
|
+
const convertRawChord = (chord: any, defaultDuration?: Duration): NoteEvent | undefined => {
|
|
296
|
+
if (!chord || chord.proto !== 'Chord') return undefined;
|
|
297
|
+
|
|
298
|
+
const pitches: Pitch[] = [];
|
|
299
|
+
for (const pitchElem of chord.pitches || []) {
|
|
300
|
+
const pitch = parseRawPitch(pitchElem.pitch);
|
|
301
|
+
if (pitch) pitches.push(pitch);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (pitches.length === 0) return undefined;
|
|
305
|
+
|
|
306
|
+
const duration = parseRawDuration(chord.duration) || defaultDuration;
|
|
307
|
+
if (!duration) return undefined;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
type: 'note',
|
|
311
|
+
pitches,
|
|
312
|
+
duration,
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
// Parse pitch name with accidental (e.g., "cf" -> { pitch: Phonet.c, accidental: Accidental.flat })
|
|
318
|
+
const parsePitchName = (name: string): { pitch: Phonet; accidental?: Accidental } | undefined => {
|
|
319
|
+
if (!name || name.length === 0) return undefined;
|
|
320
|
+
|
|
321
|
+
const phonetChar = name[0].toLowerCase();
|
|
322
|
+
const phonet = {
|
|
323
|
+
'c': Phonet.c, 'd': Phonet.d, 'e': Phonet.e, 'f': Phonet.f,
|
|
324
|
+
'g': Phonet.g, 'a': Phonet.a, 'b': Phonet.b
|
|
325
|
+
}[phonetChar];
|
|
326
|
+
|
|
327
|
+
if (!phonet) return undefined;
|
|
328
|
+
|
|
329
|
+
const accidentalPart = name.slice(1);
|
|
330
|
+
let accidental: Accidental | undefined;
|
|
331
|
+
if (accidentalPart === 's' || accidentalPart === 'is') {
|
|
332
|
+
accidental = Accidental.sharp;
|
|
333
|
+
} else if (accidentalPart === 'ss' || accidentalPart === 'isis') {
|
|
334
|
+
accidental = Accidental.doubleSharp;
|
|
335
|
+
} else if (accidentalPart === 'f' || accidentalPart === 'es') {
|
|
336
|
+
accidental = Accidental.flat;
|
|
337
|
+
} else if (accidentalPart === 'ff' || accidentalPart === 'eses') {
|
|
338
|
+
accidental = Accidental.doubleFlat;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { pitch: phonet, accidental };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Convert key from context to KeySignature
|
|
345
|
+
const convertKeySignature = (keyContext: any): KeySignature | undefined => {
|
|
346
|
+
const args = keyContext?.args;
|
|
347
|
+
|
|
348
|
+
// Always parse from args to get correct pitch and mode
|
|
349
|
+
if (Array.isArray(args) && args.length >= 2) {
|
|
350
|
+
const pitchStr = args[0];
|
|
351
|
+
const modeStr = args[1];
|
|
352
|
+
|
|
353
|
+
const pitchInfo = parsePitchName(pitchStr);
|
|
354
|
+
if (pitchInfo) {
|
|
355
|
+
const mode = modeStr?.includes('minor') ? 'minor' : 'major';
|
|
356
|
+
return {
|
|
357
|
+
pitch: pitchInfo.pitch,
|
|
358
|
+
accidental: pitchInfo.accidental,
|
|
359
|
+
mode,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Fallback to fifths lookup for compatibility (major keys only)
|
|
365
|
+
const fifths = keyContext?.key;
|
|
366
|
+
if (fifths !== undefined && KEY_FIFTHS_MAP[fifths]) {
|
|
367
|
+
const mapping = KEY_FIFTHS_MAP[fifths];
|
|
244
368
|
return {
|
|
245
369
|
pitch: mapping.pitch,
|
|
246
370
|
accidental: mapping.accidental,
|
|
247
371
|
mode: mapping.mode,
|
|
248
372
|
};
|
|
249
373
|
}
|
|
374
|
+
|
|
250
375
|
return undefined;
|
|
251
376
|
};
|
|
252
377
|
|
|
253
378
|
|
|
254
|
-
// Parse post-events to marks
|
|
255
|
-
|
|
379
|
+
// Parse post-events to marks and detect harmony events
|
|
380
|
+
interface PostEventResult {
|
|
381
|
+
marks: Mark[];
|
|
382
|
+
harmonyText?: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const parsePostEvents = (postEvents: any[]): PostEventResult => {
|
|
256
386
|
const marks: Mark[] = [];
|
|
387
|
+
let harmonyText: string | undefined;
|
|
257
388
|
|
|
258
|
-
if (!postEvents) return marks;
|
|
389
|
+
if (!postEvents) return { marks };
|
|
259
390
|
|
|
260
391
|
for (const event of postEvents) {
|
|
261
392
|
// String events
|
|
@@ -309,7 +440,31 @@ const parsePostEvents = (postEvents: any[]): Mark[] => {
|
|
|
309
440
|
} else if (cmd === 'segno') {
|
|
310
441
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
311
442
|
} else if (cmd === '\\markup' || cmd === 'markup') {
|
|
312
|
-
//
|
|
443
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
444
|
+
const harmony = extractHarmonyFromMarkup(arg.args);
|
|
445
|
+
if (harmony) {
|
|
446
|
+
harmonyText = harmony;
|
|
447
|
+
} else {
|
|
448
|
+
// Regular markup attached to note
|
|
449
|
+
const text = extractTextFromObject(arg.args);
|
|
450
|
+
if (text && !containsTempoWord(text)) {
|
|
451
|
+
const direction = event.direction;
|
|
452
|
+
const placement: Placement | undefined =
|
|
453
|
+
direction === 'up' ? Placement.above :
|
|
454
|
+
direction === 'down' ? Placement.below : undefined;
|
|
455
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Handle markup command directly (proto: 'MarkupCommand')
|
|
462
|
+
if (arg && typeof arg === 'object' && arg.proto === 'MarkupCommand') {
|
|
463
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
464
|
+
const harmony = extractHarmonyFromMarkup(arg.args);
|
|
465
|
+
if (harmony) {
|
|
466
|
+
harmonyText = harmony;
|
|
467
|
+
} else {
|
|
313
468
|
const text = extractTextFromObject(arg.args);
|
|
314
469
|
if (text && !containsTempoWord(text)) {
|
|
315
470
|
const direction = event.direction;
|
|
@@ -320,22 +475,10 @@ const parsePostEvents = (postEvents: any[]): Mark[] => {
|
|
|
320
475
|
}
|
|
321
476
|
}
|
|
322
477
|
}
|
|
323
|
-
|
|
324
|
-
// Handle markup command directly (proto: 'Command' with \\markup)
|
|
325
|
-
if (arg && typeof arg === 'object' && arg.proto === 'Command' && arg.cmd === '\\markup') {
|
|
326
|
-
const text = extractTextFromObject(arg.args);
|
|
327
|
-
if (text && !containsTempoWord(text)) {
|
|
328
|
-
const direction = event.direction;
|
|
329
|
-
const placement: Placement | undefined =
|
|
330
|
-
direction === 'up' ? Placement.above :
|
|
331
|
-
direction === 'down' ? Placement.below : undefined;
|
|
332
|
-
marks.push({ markType: 'markup', content: text, placement });
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
478
|
}
|
|
336
479
|
}
|
|
337
480
|
|
|
338
|
-
return marks;
|
|
481
|
+
return { marks, harmonyText };
|
|
339
482
|
};
|
|
340
483
|
|
|
341
484
|
|
|
@@ -353,11 +496,36 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
353
496
|
}
|
|
354
497
|
};
|
|
355
498
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
499
|
+
// Parse staff name to extract partIndex and staff number
|
|
500
|
+
// Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
|
|
501
|
+
// Falls back to partIndex=1 if format doesn't match
|
|
502
|
+
const parseStaffName = (name: string): { partIndex: number; staffNum: number } => {
|
|
503
|
+
const match = name.match(/^(\d+)_(\d+)$/);
|
|
504
|
+
if (match) {
|
|
505
|
+
return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
|
|
506
|
+
}
|
|
507
|
+
// Fallback: single part, staff number from name or 1
|
|
508
|
+
const num = parseInt(name, 10);
|
|
509
|
+
return { partIndex: 1, staffNum: isNaN(num) ? 1 : num };
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
|
|
513
|
+
// This won't be affected by \change Staff commands inside the track
|
|
514
|
+
const initialStaffName = track.contextDict?.Staff;
|
|
515
|
+
if (initialStaffName) {
|
|
516
|
+
appendStaff(initialStaffName);
|
|
359
517
|
}
|
|
360
|
-
|
|
518
|
+
const parsedStaff = initialStaffName ? parseStaffName(initialStaffName) : { partIndex: 1, staffNum: 1 };
|
|
519
|
+
// Use these as fixed values for this track - don't update from context.staffName
|
|
520
|
+
const trackStaff = parsedStaff.staffNum;
|
|
521
|
+
const trackPartIndex = parsedStaff.partIndex;
|
|
522
|
+
|
|
523
|
+
// Track emitted context events across measures for this voice
|
|
524
|
+
let lastKey: number | undefined = undefined; // Track value changes (key fifths)
|
|
525
|
+
let lastTimeSig: string | undefined = undefined; // Track value changes (as string for comparison)
|
|
526
|
+
let lastClef: Clef | undefined = undefined; // Track value changes
|
|
527
|
+
let lastOttava: number | undefined = undefined; // Track value changes
|
|
528
|
+
let lastStemDirection: string | undefined = undefined; // Track value changes
|
|
361
529
|
|
|
362
530
|
const context = new lilyParser.TrackContext(undefined, {
|
|
363
531
|
listener: (term: lilyParser.BaseTerm, context: lilyParser.TrackContext) => {
|
|
@@ -373,18 +541,13 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
373
541
|
});
|
|
374
542
|
}
|
|
375
543
|
|
|
376
|
-
// Update staff from context
|
|
377
|
-
if (context.staffName) {
|
|
378
|
-
appendStaff(context.staffName);
|
|
379
|
-
staff = staffNames.indexOf(context.staffName) + 1;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
544
|
const measure = measureMap.get(mi)!;
|
|
383
545
|
|
|
384
|
-
// Initialize voice for this track
|
|
546
|
+
// Initialize voice for this track (use fixed staff/part from track definition)
|
|
385
547
|
if (!measure.voices[vi]) {
|
|
386
548
|
measure.voices[vi] = {
|
|
387
|
-
staff,
|
|
549
|
+
staff: trackStaff,
|
|
550
|
+
partIndex: trackPartIndex,
|
|
388
551
|
events: [],
|
|
389
552
|
};
|
|
390
553
|
}
|
|
@@ -411,30 +574,62 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
411
574
|
|
|
412
575
|
// Handle music events
|
|
413
576
|
if (term instanceof lilyParser.MusicEvent) {
|
|
414
|
-
//
|
|
415
|
-
voice.staff =
|
|
577
|
+
// Staff is fixed per track (from track definition)
|
|
578
|
+
voice.staff = trackStaff;
|
|
579
|
+
|
|
580
|
+
// Handle key context change (emit when value changes)
|
|
581
|
+
if (context.key && context.key.key !== lastKey) {
|
|
582
|
+
const key = convertKeySignature(context.key);
|
|
583
|
+
if (key) {
|
|
584
|
+
voice.events.push({
|
|
585
|
+
type: 'context',
|
|
586
|
+
key,
|
|
587
|
+
});
|
|
588
|
+
lastKey = context.key.key;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Handle time signature context change (emit when value changes)
|
|
593
|
+
if (context.time) {
|
|
594
|
+
const timeSigStr = `${context.time.value.numerator}/${context.time.value.denominator}`;
|
|
595
|
+
if (timeSigStr !== lastTimeSig) {
|
|
596
|
+
voice.events.push({
|
|
597
|
+
type: 'context',
|
|
598
|
+
timeSig: {
|
|
599
|
+
numerator: context.time.value.numerator,
|
|
600
|
+
denominator: context.time.value.denominator,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
lastTimeSig = timeSigStr;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
416
606
|
|
|
417
|
-
// Handle clef context change
|
|
418
|
-
if (context.clef
|
|
607
|
+
// Handle clef context change (emit when value changes)
|
|
608
|
+
if (context.clef) {
|
|
419
609
|
const clef = LILYPOND_CLEF_MAP[context.clef.clefName];
|
|
420
|
-
if (clef) {
|
|
610
|
+
if (clef && clef !== lastClef) {
|
|
421
611
|
voice.events.push({
|
|
422
612
|
type: 'context',
|
|
423
613
|
clef,
|
|
424
614
|
});
|
|
615
|
+
lastClef = clef;
|
|
425
616
|
}
|
|
426
617
|
}
|
|
427
618
|
|
|
428
|
-
// Handle ottava
|
|
429
|
-
if (context.octave
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
619
|
+
// Handle ottava (emit when value changes)
|
|
620
|
+
if (context.octave != null) {
|
|
621
|
+
const currentOttava = context.octave.value ?? 0;
|
|
622
|
+
if (currentOttava !== lastOttava) {
|
|
623
|
+
voice.events.push({
|
|
624
|
+
type: 'context',
|
|
625
|
+
ottava: currentOttava,
|
|
626
|
+
});
|
|
627
|
+
lastOttava = currentOttava;
|
|
628
|
+
}
|
|
434
629
|
}
|
|
435
630
|
|
|
436
|
-
// Handle stem direction context
|
|
437
|
-
if (context.stemDirection &&
|
|
631
|
+
// Handle stem direction context change (emit when value changes)
|
|
632
|
+
if (context.stemDirection && context.stemDirection !== lastStemDirection) {
|
|
438
633
|
const stemDir = context.stemDirection === 'Up' ? StemDirection.up :
|
|
439
634
|
context.stemDirection === 'Down' ? StemDirection.down : undefined;
|
|
440
635
|
if (stemDir) {
|
|
@@ -442,6 +637,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
442
637
|
type: 'context',
|
|
443
638
|
stemDirection: stemDir,
|
|
444
639
|
});
|
|
640
|
+
lastStemDirection = context.stemDirection;
|
|
445
641
|
}
|
|
446
642
|
}
|
|
447
643
|
|
|
@@ -460,7 +656,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
460
656
|
}
|
|
461
657
|
|
|
462
658
|
if (pitches.length > 0) {
|
|
463
|
-
const marks = parsePostEvents(term.post_events);
|
|
659
|
+
const { marks, harmonyText } = parsePostEvents(term.post_events);
|
|
464
660
|
|
|
465
661
|
// Add beam marks
|
|
466
662
|
if (term.beamOn) {
|
|
@@ -486,6 +682,15 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
486
682
|
}
|
|
487
683
|
|
|
488
684
|
voice.events.push(noteEvent);
|
|
685
|
+
|
|
686
|
+
// Add harmony event if detected (chord symbol encoded as \bold markup)
|
|
687
|
+
if (harmonyText) {
|
|
688
|
+
const harmonyEvent: HarmonyEvent = {
|
|
689
|
+
type: 'harmony',
|
|
690
|
+
text: harmonyText,
|
|
691
|
+
};
|
|
692
|
+
voice.events.push(harmonyEvent);
|
|
693
|
+
}
|
|
489
694
|
}
|
|
490
695
|
}
|
|
491
696
|
// Process Rest
|
|
@@ -508,40 +713,45 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
508
713
|
voice.events.push(restEvent);
|
|
509
714
|
}
|
|
510
715
|
}
|
|
511
|
-
// Handle standalone stem direction
|
|
716
|
+
// Handle standalone stem direction (emit when value changes)
|
|
512
717
|
else if (term instanceof lilyParser.LilyTerms.StemDirection) {
|
|
513
|
-
|
|
514
|
-
term.direction === '
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
718
|
+
if (term.direction !== lastStemDirection) {
|
|
719
|
+
const stemDir = term.direction === 'Up' ? StemDirection.up :
|
|
720
|
+
term.direction === 'Down' ? StemDirection.down : undefined;
|
|
721
|
+
if (stemDir) {
|
|
722
|
+
voice.events.push({
|
|
723
|
+
type: 'context',
|
|
724
|
+
stemDirection: stemDir,
|
|
725
|
+
});
|
|
726
|
+
lastStemDirection = term.direction;
|
|
727
|
+
}
|
|
520
728
|
}
|
|
521
729
|
}
|
|
522
|
-
// Handle standalone clef
|
|
730
|
+
// Handle standalone clef (emit when value changes)
|
|
523
731
|
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
524
732
|
const clef = LILYPOND_CLEF_MAP[term.clefName];
|
|
525
|
-
if (clef) {
|
|
733
|
+
if (clef && clef !== lastClef) {
|
|
526
734
|
voice.events.push({
|
|
527
735
|
type: 'context',
|
|
528
736
|
clef,
|
|
529
737
|
});
|
|
738
|
+
lastClef = clef;
|
|
530
739
|
}
|
|
531
740
|
}
|
|
532
741
|
// Handle ottava shift
|
|
533
742
|
else if (term instanceof lilyParser.LilyTerms.OctaveShift) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
743
|
+
if (term.value !== lastOttava) {
|
|
744
|
+
voice.events.push({
|
|
745
|
+
type: 'context',
|
|
746
|
+
ottava: term.value,
|
|
747
|
+
});
|
|
748
|
+
lastOttava = term.value;
|
|
749
|
+
}
|
|
538
750
|
}
|
|
539
751
|
// Handle staff change
|
|
540
752
|
else if (term instanceof lilyParser.LilyTerms.Change) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
voice.staff = staff;
|
|
544
|
-
}
|
|
753
|
+
// Ignore \change Staff commands - staff is fixed per track
|
|
754
|
+
// (Cross-staff notation is not supported in this decoder)
|
|
545
755
|
}
|
|
546
756
|
// Handle tempo
|
|
547
757
|
else if (term instanceof lilyParser.LilyTerms.Tempo) {
|
|
@@ -553,17 +763,146 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
553
763
|
});
|
|
554
764
|
}
|
|
555
765
|
}
|
|
556
|
-
// Handle standalone markup command
|
|
766
|
+
// Handle standalone markup command and barlines
|
|
557
767
|
else {
|
|
558
768
|
const termAny = term as any;
|
|
559
769
|
if (termAny.proto === 'Command' && (termAny.cmd === '\\markup' || termAny.cmd === 'markup')) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
770
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
771
|
+
const harmonyText = extractHarmonyFromMarkup(termAny.args);
|
|
772
|
+
if (harmonyText) {
|
|
773
|
+
const harmonyEvent: HarmonyEvent = {
|
|
774
|
+
type: 'harmony',
|
|
775
|
+
text: harmonyText,
|
|
565
776
|
};
|
|
566
|
-
voice.events.push(
|
|
777
|
+
voice.events.push(harmonyEvent);
|
|
778
|
+
} else {
|
|
779
|
+
const text = extractTextFromObject(termAny.args);
|
|
780
|
+
if (text && !containsTempoWord(text)) {
|
|
781
|
+
const markupEvent: MarkupEvent = {
|
|
782
|
+
type: 'markup',
|
|
783
|
+
content: text,
|
|
784
|
+
};
|
|
785
|
+
voice.events.push(markupEvent);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Handle barline command - barlines belong to the previous measure
|
|
790
|
+
else if (termAny.proto === 'Command' && termAny.cmd === 'bar') {
|
|
791
|
+
const style = termAny.args?.[0]?.exp;
|
|
792
|
+
if (style && mi > 0) {
|
|
793
|
+
// Remove quotes from the style string
|
|
794
|
+
const barStyle = style.replace(/^"|"$/g, '');
|
|
795
|
+
// Add to previous measure's voice
|
|
796
|
+
const prevMeasure = measureMap.get(mi - 1);
|
|
797
|
+
if (prevMeasure && prevMeasure.voices[vi]) {
|
|
798
|
+
prevMeasure.voices[vi].events.push({
|
|
799
|
+
type: 'barline',
|
|
800
|
+
style: barStyle,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// Handle ChordSymbol (inline chord symbol: \chords "text")
|
|
806
|
+
else if (termAny.proto === 'ChordSymbol') {
|
|
807
|
+
// Extract text from LiteralString (e.g., { exp: '"C"' } -> "C")
|
|
808
|
+
let text = termAny.text;
|
|
809
|
+
if (typeof text === 'object' && text?.exp) {
|
|
810
|
+
text = text.exp.replace(/^"|"$/g, '');
|
|
811
|
+
} else if (typeof text === 'string') {
|
|
812
|
+
text = text.replace(/^"|"$/g, '');
|
|
813
|
+
}
|
|
814
|
+
const harmonyEvent: HarmonyEvent = {
|
|
815
|
+
type: 'harmony',
|
|
816
|
+
text: text,
|
|
817
|
+
};
|
|
818
|
+
voice.events.push(harmonyEvent);
|
|
819
|
+
}
|
|
820
|
+
// Handle tuplet
|
|
821
|
+
// Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
|
|
822
|
+
// remove the already-added notes and wrap them in a TupletEvent
|
|
823
|
+
else if (termAny.proto === 'Tuplet') {
|
|
824
|
+
const ratioStr = termAny.args?.[0]; // e.g., "3/2"
|
|
825
|
+
const body = termAny.args?.[1]?.body || [];
|
|
826
|
+
|
|
827
|
+
if (ratioStr && body.length > 0) {
|
|
828
|
+
// Parse ratio string
|
|
829
|
+
const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
|
|
830
|
+
if (ratioMatch) {
|
|
831
|
+
const [, num, denom] = ratioMatch;
|
|
832
|
+
const ratio: Fraction = {
|
|
833
|
+
numerator: parseInt(denom, 10), // Swapped: lilylet uses actual/normal
|
|
834
|
+
denominator: parseInt(num, 10),
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// Count how many note/rest events are in the tuplet body
|
|
838
|
+
const noteCount = body.filter((item: any) =>
|
|
839
|
+
item.proto === 'Chord' || item.proto === 'Rest'
|
|
840
|
+
).length;
|
|
841
|
+
|
|
842
|
+
// Remove the last noteCount note/rest events from voice.events
|
|
843
|
+
// (they were already added by the Chord/Rest handlers)
|
|
844
|
+
const tupletEvents: Event[] = [];
|
|
845
|
+
let removed = 0;
|
|
846
|
+
while (removed < noteCount && voice.events.length > 0) {
|
|
847
|
+
const lastEvent = voice.events[voice.events.length - 1];
|
|
848
|
+
if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
|
|
849
|
+
tupletEvents.unshift(voice.events.pop()!);
|
|
850
|
+
removed++;
|
|
851
|
+
} else {
|
|
852
|
+
break; // Stop if we hit a non-note/rest event
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (tupletEvents.length > 0) {
|
|
857
|
+
const tupletEvent: TupletEvent = {
|
|
858
|
+
type: 'tuplet',
|
|
859
|
+
ratio,
|
|
860
|
+
events: tupletEvents,
|
|
861
|
+
};
|
|
862
|
+
voice.events.push(tupletEvent);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Handle repeat tremolo
|
|
868
|
+
else if (termAny.proto === 'Repeat' && termAny.args?.[0] === 'tremolo') {
|
|
869
|
+
const count = parseInt(termAny.args?.[1], 10);
|
|
870
|
+
const body = termAny.args?.[2]?.body || [];
|
|
871
|
+
|
|
872
|
+
if (!isNaN(count) && body.length === 2) {
|
|
873
|
+
// Double tremolo has exactly 2 pitches
|
|
874
|
+
const pitch1 = body[0]?.pitches?.[0]?.pitch;
|
|
875
|
+
const pitch2 = body[1]?.pitches?.[0]?.pitch;
|
|
876
|
+
const duration = body[0]?.duration;
|
|
877
|
+
|
|
878
|
+
if (pitch1 && pitch2 && duration) {
|
|
879
|
+
const pitchA = parseRawPitch(pitch1);
|
|
880
|
+
const pitchB = parseRawPitch(pitch2);
|
|
881
|
+
const div = parseInt(duration.number, 10);
|
|
882
|
+
|
|
883
|
+
if (pitchA && pitchB && !isNaN(div)) {
|
|
884
|
+
// Remove the 2 notes that were already added
|
|
885
|
+
let removed = 0;
|
|
886
|
+
while (removed < 2 && voice.events.length > 0) {
|
|
887
|
+
const lastEvent = voice.events[voice.events.length - 1];
|
|
888
|
+
if (lastEvent.type === 'note') {
|
|
889
|
+
voice.events.pop();
|
|
890
|
+
removed++;
|
|
891
|
+
} else {
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const tremoloEvent: TremoloEvent = {
|
|
897
|
+
type: 'tremolo',
|
|
898
|
+
pitchA: [pitchA],
|
|
899
|
+
pitchB: [pitchB],
|
|
900
|
+
count,
|
|
901
|
+
division: div,
|
|
902
|
+
};
|
|
903
|
+
voice.events.push(tremoloEvent);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
567
906
|
}
|
|
568
907
|
}
|
|
569
908
|
}
|
|
@@ -590,6 +929,8 @@ const hasRealContent = (events: Event[]): boolean => {
|
|
|
590
929
|
return events.some(e => {
|
|
591
930
|
if (e.type === 'note') return true;
|
|
592
931
|
if (e.type === 'rest' && !(e as RestEvent).invisible) return true;
|
|
932
|
+
if (e.type === 'tuplet') return true;
|
|
933
|
+
if (e.type === 'tremolo') return true;
|
|
593
934
|
return false;
|
|
594
935
|
});
|
|
595
936
|
};
|
|
@@ -672,6 +1013,43 @@ const extractTextFromObject = (obj: any): string | undefined => {
|
|
|
672
1013
|
};
|
|
673
1014
|
|
|
674
1015
|
|
|
1016
|
+
// Check if markup contains \bold command (indicates harmony/chord symbol)
|
|
1017
|
+
// Returns the text if it's a harmony, undefined otherwise
|
|
1018
|
+
const extractHarmonyFromMarkup = (obj: any): string | undefined => {
|
|
1019
|
+
if (!obj) return undefined;
|
|
1020
|
+
|
|
1021
|
+
// Check array of args
|
|
1022
|
+
if (Array.isArray(obj)) {
|
|
1023
|
+
for (const item of obj) {
|
|
1024
|
+
const result = extractHarmonyFromMarkup(item);
|
|
1025
|
+
if (result !== undefined) return result;
|
|
1026
|
+
}
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (obj && typeof obj === 'object') {
|
|
1031
|
+
// Check if this is a \bold command (can be Command or MarkupCommand)
|
|
1032
|
+
if ((obj.proto === 'Command' || obj.proto === 'MarkupCommand') &&
|
|
1033
|
+
(obj.cmd === 'bold' || obj.cmd === '\\bold')) {
|
|
1034
|
+
// Extract the text from args
|
|
1035
|
+
return extractTextFromObject(obj.args);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Recursively search InlineBlock body
|
|
1039
|
+
if (obj.proto === 'InlineBlock' && obj.body) {
|
|
1040
|
+
return extractHarmonyFromMarkup(obj.body);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Recursively search args
|
|
1044
|
+
if (obj.args) {
|
|
1045
|
+
return extractHarmonyFromMarkup(obj.args);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return undefined;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
|
|
675
1053
|
// Extract string value from header field
|
|
676
1054
|
const extractStringValue = (value: any): string | undefined => {
|
|
677
1055
|
const text = extractTextFromObject(value);
|
|
@@ -726,17 +1104,30 @@ const extractMetadata = (lilyDocument: lilyParser.LilyDocument): Metadata | unde
|
|
|
726
1104
|
const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadata): LilyletDoc => {
|
|
727
1105
|
const measures: Measure[] = parsedMeasures.map(pm => {
|
|
728
1106
|
// Filter out voices that only contain spacer rests and context changes
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
1107
|
+
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
1108
|
+
|
|
1109
|
+
// Group voices by partIndex
|
|
1110
|
+
const partMap = new Map<number, Array<{ staff: number; events: Event[] }>>();
|
|
1111
|
+
for (const v of filteredVoices) {
|
|
1112
|
+
const pi = v.partIndex || 1;
|
|
1113
|
+
if (!partMap.has(pi)) {
|
|
1114
|
+
partMap.set(pi, []);
|
|
1115
|
+
}
|
|
1116
|
+
partMap.get(pi)!.push({
|
|
732
1117
|
staff: v.staff,
|
|
733
1118
|
events: v.events,
|
|
734
|
-
})
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Convert to parts array (sorted by part index)
|
|
1123
|
+
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1124
|
+
const parts = partIndices.map(pi => ({
|
|
1125
|
+
voices: partMap.get(pi)!,
|
|
1126
|
+
}));
|
|
735
1127
|
|
|
1128
|
+
// Fallback to single empty part if no voices
|
|
736
1129
|
const measure: Measure = {
|
|
737
|
-
parts: [{
|
|
738
|
-
voices,
|
|
739
|
-
}],
|
|
1130
|
+
parts: parts.length > 0 ? parts : [{ voices: [] }],
|
|
740
1131
|
};
|
|
741
1132
|
|
|
742
1133
|
if (pm.key !== null) {
|
|
@@ -750,7 +1141,9 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
|
|
|
750
1141
|
}
|
|
751
1142
|
|
|
752
1143
|
return measure;
|
|
753
|
-
})
|
|
1144
|
+
})
|
|
1145
|
+
// Filter out empty measures (no voices in any part)
|
|
1146
|
+
.filter(m => m.parts.some(p => p.voices.length > 0));
|
|
754
1147
|
|
|
755
1148
|
const doc: LilyletDoc = { measures };
|
|
756
1149
|
if (metadata) {
|
|
@@ -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
|
|