@k-l-lambda/lilylet 0.1.39 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ time: {
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: (NoteEvent | RestEvent)[] = [];
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()! as NoteEvent | RestEvent);
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) {