@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.
@@ -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.38",
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.3"
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
- // Convert key fifths to KeySignature
241
- const convertKeySignature = (fifths: number): KeySignature | undefined => {
242
- const mapping = KEY_FIFTHS_MAP[fifths];
243
- if (mapping) {
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
- const parsePostEvents = (postEvents: any[]): Mark[] => {
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
- // Markup attached to note
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
- const staffName = track.contextDict?.Staff;
357
- if (staffName) {
358
- appendStaff(staffName);
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
- let staff = staffName ? staffNames.indexOf(staffName) + 1 : 1;
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
- // Update staff from voice events
415
- voice.staff = 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 && !voice.events.some(e => e.type === 'context' && (e as ContextChange).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?.value && !voice.events.some(e => e.type === 'context' && (e as ContextChange).ottava !== undefined)) {
430
- voice.events.push({
431
- type: 'context',
432
- ottava: context.octave.value,
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 && !voice.events.some(e => e.type === 'context' && (e as ContextChange).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
- const stemDir = term.direction === 'Up' ? StemDirection.up :
514
- term.direction === 'Down' ? StemDirection.down : undefined;
515
- if (stemDir) {
516
- voice.events.push({
517
- type: 'context',
518
- stemDirection: stemDir,
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
- voice.events.push({
535
- type: 'context',
536
- ottava: term.value,
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
- if (term.args?.[0]?.key === 'Staff') {
542
- // Staff change mid-voice
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
- const text = extractTextFromObject(termAny.args);
561
- if (text && !containsTempoWord(text)) {
562
- const markupEvent: MarkupEvent = {
563
- type: 'markup',
564
- content: text,
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(markupEvent);
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 voices = pm.voices
730
- .filter(v => hasRealContent(v.events))
731
- .map(v => ({
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 `^\\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