@k-l-lambda/lilylet 0.1.33 → 0.1.35

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/meiEncoder.js CHANGED
@@ -470,14 +470,13 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
470
470
  return `${indent}<rest ${attrs} />\n`;
471
471
  };
472
472
  // Convert TupletEvent to MEI
473
- const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0) => {
473
+ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
474
474
  // LilyPond \times 2/3 means "multiply duration by 2/3"
475
475
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
476
476
  // MEI: num = number of notes written, numbase = normal equivalent
477
477
  const num = event.ratio.denominator; // denominator = actual note count
478
478
  const numbase = event.ratio.numerator; // numerator = time equivalent
479
479
  let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
480
- let inBeam = false;
481
480
  const baseIndent = indent + ' ';
482
481
  // Effective staff for cross-staff notation
483
482
  const effectiveStaff = currentStaff ?? layerStaff;
@@ -490,28 +489,17 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
490
489
  const mordents = [];
491
490
  const turns = [];
492
491
  const arpeggios = [];
492
+ // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
493
+ // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
494
+ // Beam state is managed by encodeLayer, not here.
493
495
  for (const e of event.events) {
494
- // Check for beam marks in note events
495
- let beamStart = false;
496
- let beamEnd = false;
497
- if (e.type === 'note') {
498
- const markOptions = extractMarkOptions(e.marks);
499
- beamStart = markOptions.beamStart;
500
- beamEnd = markOptions.beamEnd;
501
- }
502
- // Open beam element if beam starts
503
- if (beamStart && !inBeam) {
504
- xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
505
- inBeam = true;
506
- }
507
- const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
508
496
  if (e.type === 'note') {
509
497
  // For cross-staff notation: set note's staff if different from layerStaff
510
498
  const noteEvent = e;
511
499
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
512
500
  ? { ...noteEvent, staff: effectiveStaff }
513
501
  : noteEvent;
514
- const result = noteEventToMEI(effectiveNoteEvent, currentIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
502
+ const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
515
503
  xml += result.xml;
516
504
  // Collect slur info
517
505
  if (result.slurStart)
@@ -533,18 +521,9 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
533
521
  arpeggios.push({ plist: result.elementId });
534
522
  }
535
523
  else if (e.type === 'rest') {
536
- xml += restEventToMEI(e, currentIndent, keyFifths, ottavaShift);
537
- }
538
- // Close beam element if beam ends
539
- if (beamEnd && inBeam) {
540
- xml += `${baseIndent}</beam>\n`;
541
- inBeam = false;
524
+ xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
542
525
  }
543
526
  }
544
- // Close any unclosed beam
545
- if (inBeam) {
546
- xml += `${baseIndent}</beam>\n`;
547
- }
548
527
  xml += `${indent}</tuplet>\n`;
549
528
  return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
550
529
  };
@@ -615,11 +594,34 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
615
594
  result += `${indent}</fTrem>\n`;
616
595
  return result;
617
596
  };
597
+ // Helper: check if an event (or any note inside a tuplet) has beam start/end
598
+ const getEventBeamMarks = (event) => {
599
+ if (event.type === 'note') {
600
+ const markOptions = extractMarkOptions(event.marks);
601
+ return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
602
+ }
603
+ if (event.type === 'tuplet') {
604
+ const tuplet = event;
605
+ let beamStart = false;
606
+ let beamEnd = false;
607
+ for (const e of tuplet.events) {
608
+ if (e.type === 'note') {
609
+ const markOptions = extractMarkOptions(e.marks);
610
+ if (markOptions.beamStart)
611
+ beamStart = true;
612
+ if (markOptions.beamEnd)
613
+ beamEnd = true;
614
+ }
615
+ }
616
+ return { beamStart, beamEnd };
617
+ }
618
+ return { beamStart: false, beamEnd: false };
619
+ };
618
620
  // Encode a layer (voice)
619
621
  const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null) => {
620
622
  const layerId = generateId("layer");
621
623
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
622
- let inBeam = false;
624
+ let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
623
625
  const baseIndent = indent + ' ';
624
626
  // Track current clef to only emit changes
625
627
  let currentClef = initialClef;
@@ -667,21 +669,14 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
667
669
  return true;
668
670
  };
669
671
  for (const event of voice.events) {
670
- // Check for beam start/end in note events
671
- let beamStart = false;
672
- let beamEnd = false;
673
- if (event.type === 'note') {
674
- const noteEvent = event;
675
- const markOptions = extractMarkOptions(noteEvent.marks);
676
- beamStart = markOptions.beamStart;
677
- beamEnd = markOptions.beamEnd;
678
- }
672
+ // Check for beam start/end in this event (including inside tuplets)
673
+ const { beamStart, beamEnd } = getEventBeamMarks(event);
679
674
  // Open beam element if beam starts
680
- if (beamStart && !inBeam) {
675
+ if (beamStart && !beamElementOpen) {
681
676
  xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
682
- inBeam = true;
677
+ beamElementOpen = true;
683
678
  }
684
- const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
679
+ const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
685
680
  switch (event.type) {
686
681
  case 'note': {
687
682
  const noteEvent = event;
@@ -790,7 +785,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
790
785
  xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
791
786
  break;
792
787
  case 'tuplet': {
793
- const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift);
788
+ // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
789
+ // Pass beamElementOpen to tuplet so it knows not to create its own beam
790
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
794
791
  xml += tupletResult.xml;
795
792
  // Process slur ends first (to close any pending slurs from before this tuplet)
796
793
  for (const endId of tupletResult.slurEnds) {
@@ -883,13 +880,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
883
880
  break;
884
881
  }
885
882
  // Close beam element if beam ends
886
- if (beamEnd && inBeam) {
883
+ if (beamEnd && beamElementOpen) {
887
884
  xml += `${baseIndent}</beam>\n`;
888
- inBeam = false;
885
+ beamElementOpen = false;
889
886
  }
890
887
  }
891
888
  // Close any unclosed beam
892
- if (inBeam) {
889
+ if (beamElementOpen) {
893
890
  xml += `${baseIndent}</beam>\n`;
894
891
  }
895
892
  // Close any unclosed ottava span at end of layer
@@ -1240,9 +1237,11 @@ const analyzePartStructure = (doc) => {
1240
1237
  return partInfos;
1241
1238
  };
1242
1239
  // Encode scoreDef with part groups
1243
- const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent) => {
1240
+ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
1244
1241
  const scoreDefId = generateId("scoredef");
1245
- let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}" meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1242
+ // Build meter attributes
1243
+ const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1244
+ let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1246
1245
  xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1247
1246
  for (let pi = 0; pi < partInfos.length; pi++) {
1248
1247
  const info = partInfos[pi];
@@ -1284,6 +1283,7 @@ const encode = (doc, options = {}) => {
1284
1283
  let currentKey = 0;
1285
1284
  let currentTimeNum = 4;
1286
1285
  let currentTimeDen = 4;
1286
+ let currentMeterSymbol = undefined;
1287
1287
  const firstMeasure = doc.measures[0];
1288
1288
  if (firstMeasure.key) {
1289
1289
  currentKey = keyToFifths(firstMeasure.key);
@@ -1291,6 +1291,7 @@ const encode = (doc, options = {}) => {
1291
1291
  if (firstMeasure.timeSig) {
1292
1292
  currentTimeNum = firstMeasure.timeSig.numerator;
1293
1293
  currentTimeDen = firstMeasure.timeSig.denominator;
1294
+ currentMeterSymbol = firstMeasure.timeSig.symbol;
1294
1295
  }
1295
1296
  const keySig = KEY_SIGS[currentKey] || "0";
1296
1297
  // Build MEI document
@@ -1335,7 +1336,7 @@ const encode = (doc, options = {}) => {
1335
1336
  mei += `${indent}${indent}<body>\n`;
1336
1337
  mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1337
1338
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1338
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`);
1339
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
1339
1340
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1340
1341
  // Track tie state across measures for cross-measure ties
1341
1342
  const tieState = {};
@@ -1384,6 +1385,20 @@ const encode = (doc, options = {}) => {
1384
1385
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
1385
1386
  }
1386
1387
  }
1388
+ // Check for time signature change and output scoreDef if needed
1389
+ if (measure.timeSig && mi > 0) {
1390
+ const newTimeNum = measure.timeSig.numerator;
1391
+ const newTimeDen = measure.timeSig.denominator;
1392
+ const newMeterSymbol = measure.timeSig.symbol;
1393
+ if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
1394
+ currentTimeNum = newTimeNum;
1395
+ currentTimeDen = newTimeDen;
1396
+ currentMeterSymbol = newMeterSymbol;
1397
+ // Output a scoreDef with the new time signature
1398
+ const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
1399
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1400
+ }
1401
+ }
1387
1402
  mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
1388
1403
  });
1389
1404
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
@@ -60,6 +60,75 @@ class SpannerTracker {
60
60
  this.ties.clear();
61
61
  }
62
62
  }
63
+ // ============ Tuplet Tracker ============
64
+ /**
65
+ * Track tuplet groups by number attribute.
66
+ * Collects notes between tuplet start and stop to create TupletEvent.
67
+ */
68
+ class TupletTracker {
69
+ // Map from tuplet number to collected events and ratio
70
+ activeTuplets = new Map();
71
+ /**
72
+ * Start a new tuplet group
73
+ */
74
+ startTuplet(number = 1) {
75
+ this.activeTuplets.set(number, { events: [] });
76
+ }
77
+ /**
78
+ * Add an event to active tuplet(s)
79
+ * Returns true if the event was added to at least one tuplet
80
+ */
81
+ addEvent(event) {
82
+ if (this.activeTuplets.size === 0)
83
+ return false;
84
+ // Add to all active tuplets (in case of nested tuplets)
85
+ for (const [, tuplet] of this.activeTuplets) {
86
+ // Set ratio from first event's duration.tuplet
87
+ if (!tuplet.ratio && event.duration.tuplet) {
88
+ // In Lilylet, ratio is denominator/numerator (e.g., 2/3 for triplet)
89
+ tuplet.ratio = {
90
+ numerator: event.duration.tuplet.denominator,
91
+ denominator: event.duration.tuplet.numerator,
92
+ };
93
+ }
94
+ // Store event without tuplet info in duration (it's handled at TupletEvent level)
95
+ const cleanEvent = { ...event, duration: { ...event.duration } };
96
+ delete cleanEvent.duration.tuplet;
97
+ tuplet.events.push(cleanEvent);
98
+ }
99
+ return true;
100
+ }
101
+ /**
102
+ * Stop a tuplet group and return the TupletEvent
103
+ */
104
+ stopTuplet(number = 1) {
105
+ const tuplet = this.activeTuplets.get(number);
106
+ if (!tuplet || tuplet.events.length === 0) {
107
+ this.activeTuplets.delete(number);
108
+ return undefined;
109
+ }
110
+ this.activeTuplets.delete(number);
111
+ // Default ratio if not set (shouldn't happen normally)
112
+ const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
113
+ return {
114
+ type: 'tuplet',
115
+ ratio,
116
+ events: tuplet.events,
117
+ };
118
+ }
119
+ /**
120
+ * Check if any tuplet is active
121
+ */
122
+ isActive() {
123
+ return this.activeTuplets.size > 0;
124
+ }
125
+ /**
126
+ * Reset tracker
127
+ */
128
+ reset() {
129
+ this.activeTuplets.clear();
130
+ }
131
+ }
63
132
  class VoiceTracker {
64
133
  voices = new Map();
65
134
  currentPosition = 0;
@@ -737,7 +806,7 @@ const directionToMarks = (direction, spannerTracker) => {
737
806
  /**
738
807
  * Convert a MusicXML measure to Lilylet events, grouped by voice
739
808
  */
740
- const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker) => {
809
+ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker) => {
741
810
  let key;
742
811
  let timeSig;
743
812
  let barline;
@@ -782,6 +851,11 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
782
851
  const voiceNum = note.voice;
783
852
  const staffNum = note.staff || 1;
784
853
  currentVoice = voiceNum;
854
+ // Check for tuplet start BEFORE processing the note
855
+ const tupletNotation = note.notations?.tuplet;
856
+ if (tupletNotation?.type === 'start') {
857
+ tupletTracker.startTuplet(tupletNotation.number);
858
+ }
785
859
  // Add any pending context changes before the note (tempo, ottava)
786
860
  if (pendingContextChanges.length > 0) {
787
861
  for (const ctx of pendingContextChanges) {
@@ -801,7 +875,13 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
801
875
  };
802
876
  // Grace notes don't advance time
803
877
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
804
- voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
878
+ // Check if we're in a tuplet
879
+ if (tupletTracker.isActive()) {
880
+ tupletTracker.addEvent(restEvent);
881
+ }
882
+ else {
883
+ voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
884
+ }
805
885
  }
806
886
  else if (note.pitch) {
807
887
  // Note or chord - convert MusicXmlPitch to Lilylet Pitch
@@ -858,7 +938,30 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
858
938
  }
859
939
  // Grace notes don't advance time
860
940
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
861
- voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
941
+ // Check if we're in a tuplet
942
+ if (tupletTracker.isActive()) {
943
+ tupletTracker.addEvent(noteEvent);
944
+ }
945
+ else {
946
+ voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
947
+ }
948
+ }
949
+ // Check for tuplet stop AFTER processing the note
950
+ if (tupletNotation?.type === 'stop') {
951
+ const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
952
+ if (tupletEvent) {
953
+ // Calculate total duration of tuplet for voiceTracker
954
+ let totalDuration = 0;
955
+ for (const evt of tupletEvent.events) {
956
+ if (evt.duration) {
957
+ // Convert division to duration units (quarter = 1)
958
+ totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
959
+ }
960
+ }
961
+ // Apply tuplet ratio to get actual duration
962
+ totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
963
+ voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
964
+ }
862
965
  }
863
966
  }
864
967
  else if (tagName === 'direction') {
@@ -917,6 +1020,7 @@ const convertPart = (partEl) => {
917
1020
  const voiceTracker = new VoiceTracker();
918
1021
  const spannerTracker = new SpannerTracker();
919
1022
  const ottavaTracker = { current: 0 };
1023
+ const tupletTracker = new TupletTracker();
920
1024
  let lastKey;
921
1025
  let lastTimeSig;
922
1026
  let isFirstMeasure = true;
@@ -924,7 +1028,7 @@ const convertPart = (partEl) => {
924
1028
  const measureEls = getDirectChildren(partEl, 'measure');
925
1029
  for (const measureEl of measureEls) {
926
1030
  voiceTracker.reset();
927
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker);
1031
+ const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
928
1032
  // Update running key/time
929
1033
  if (key)
930
1034
  lastKey = key;
package/lib/serializer.js CHANGED
@@ -448,7 +448,13 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
448
448
  parts.push('\\key ' + keyStr);
449
449
  }
450
450
  if (measureContext.time) {
451
- parts.push('\\time ' + measureContext.time.numerator + '/' + measureContext.time.denominator);
451
+ const { numerator, denominator, symbol } = measureContext.time;
452
+ // Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
453
+ // (meaning numeric display was explicitly requested)
454
+ if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
455
+ parts.push('\\numericTimeSignature');
456
+ }
457
+ parts.push('\\time ' + numerator + '/' + denominator);
452
458
  }
453
459
  }
454
460
  for (const event of voice.events) {
package/lib/types.d.ts CHANGED
@@ -83,6 +83,9 @@ export interface Fraction {
83
83
  numerator: number;
84
84
  denominator: number;
85
85
  }
86
+ export interface TimeSig extends Fraction {
87
+ symbol?: 'common' | 'cut';
88
+ }
86
89
  export interface Pitch {
87
90
  phonet: Phonet;
88
91
  accidental?: Accidental;
@@ -231,7 +234,7 @@ export interface Part {
231
234
  }
232
235
  export interface Measure {
233
236
  key?: KeySignature;
234
- timeSig?: Fraction;
237
+ timeSig?: TimeSig;
235
238
  parts: Part[];
236
239
  partial?: boolean;
237
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
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",