@k-l-lambda/lilylet 0.1.66 → 0.1.68

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.
@@ -44,6 +44,40 @@ const CLEF_SHAPES = {
44
44
  F: { shape: "F", line: 4 },
45
45
  C: { shape: "C", line: 3 },
46
46
  };
47
+ // Resolve a clef string into MEI shape/line plus optional octave displacement.
48
+ // Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
49
+ // the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
50
+ // and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
51
+ // and dis.place ("below" | "above").
52
+ const resolveClef = (clefStr) => {
53
+ const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
54
+ const base = match ? match[1] : clefStr;
55
+ const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
56
+ if (!match)
57
+ return { shape: clefInfo.shape, line: clefInfo.line };
58
+ return {
59
+ shape: clefInfo.shape,
60
+ line: clefInfo.line,
61
+ dis: match[3],
62
+ disPlace: match[2] === "^" ? "above" : "below",
63
+ };
64
+ };
65
+ // Attributes for a standalone <clef> element (mid-measure clef change).
66
+ const clefElementAttrs = (clefStr) => {
67
+ const c = resolveClef(clefStr);
68
+ let attrs = `shape="${c.shape}" line="${c.line}"`;
69
+ if (c.dis)
70
+ attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
71
+ return attrs;
72
+ };
73
+ // Attributes for a <staffDef> clef (clef.* namespace).
74
+ const staffDefClefAttrs = (clefStr) => {
75
+ const c = resolveClef(clefStr);
76
+ let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
77
+ if (c.dis)
78
+ attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
79
+ return attrs;
80
+ };
47
81
  // Lilylet duration division to MEI dur
48
82
  // division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
49
83
  const DURATIONS = {
@@ -504,7 +538,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
504
538
  // Convert RestEvent to MEI
505
539
  const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
506
540
  const dur = DURATIONS[event.duration.division] || "4";
507
- let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
541
+ const restId = generateId('rest');
542
+ let attrs = `xml:id="${restId}" dur="${dur}"`;
508
543
  if (event.duration.dots > 0)
509
544
  attrs += ` dots="${event.duration.dots}"`;
510
545
  // Cross-staff attribute
@@ -517,13 +552,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
517
552
  }
518
553
  // Space rest (invisible)
519
554
  if (event.invisible) {
520
- return `${indent}<space ${attrs} />\n`;
555
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
521
556
  }
522
557
  // Full measure rest
523
558
  if (event.fullMeasure) {
524
- return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
559
+ const mRestId = generateId('mrest');
560
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
525
561
  }
526
- return `${indent}<rest ${attrs} />\n`;
562
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
527
563
  };
528
564
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
529
565
  // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
@@ -611,7 +647,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
611
647
  }
612
648
  else if (e.type === 'rest') {
613
649
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
614
- xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
650
+ xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
615
651
  }
616
652
  else if (e.type === 'context') {
617
653
  const ctx = e;
@@ -620,8 +656,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
620
656
  const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
621
657
  if (effectiveStaffNum === layerStaffNum) {
622
658
  const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
623
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
624
- xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
659
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
625
660
  }
626
661
  activeClef = ctx.clef;
627
662
  endingClef = ctx.clef;
@@ -776,6 +811,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
776
811
  const barlines = [];
777
812
  const markups = [];
778
813
  const pendingMarkups = [];
814
+ const pendingDynamics = [];
779
815
  // Track current stem direction from context changes
780
816
  let currentStemDirection = undefined;
781
817
  // Track current staff for cross-staff notation
@@ -791,6 +827,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
791
827
  }
792
828
  pendingMarkups.length = 0;
793
829
  };
830
+ // Helper to flush pending leading dynamics onto a note ID
831
+ const flushPendingDynamics = (noteId) => {
832
+ for (const label of pendingDynamics) {
833
+ dynamics.push({ startid: noteId, label });
834
+ }
835
+ pendingDynamics.length = 0;
836
+ };
794
837
  // Helper to check if pitches match for tie continuation
795
838
  const pitchesMatch = (p1, p2) => {
796
839
  if (p1.length !== p2.length)
@@ -851,6 +894,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
851
894
  }
852
895
  // Flush any pending markups onto this note
853
896
  flushPendingMarkups(result.elementId);
897
+ // Flush any pending leading dynamics onto this note
898
+ flushPendingDynamics(result.elementId);
854
899
  // If there's a pending ottava, start the span on this note
855
900
  if (pendingOttava !== null && pendingOttava !== 0) {
856
901
  const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -963,7 +1008,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
963
1008
  case 'rest': {
964
1009
  // For cross-staff notation: pass staff number if different from voice's home staff
965
1010
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
966
- xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1011
+ const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1012
+ xml += restResult.xml;
1013
+ // A leading dynamic/markup attaches to the next event, which may be this rest
1014
+ flushPendingMarkups(restResult.elementId);
1015
+ flushPendingDynamics(restResult.elementId);
1016
+ lastNoteId = restResult.elementId;
967
1017
  break;
968
1018
  }
969
1019
  case 'tuplet':
@@ -979,6 +1029,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
979
1029
  // Flush any pending markups onto the first note of the tuplet
980
1030
  if (tupletResult.firstNoteId) {
981
1031
  flushPendingMarkups(tupletResult.firstNoteId);
1032
+ flushPendingDynamics(tupletResult.firstNoteId);
982
1033
  lastNoteId = tupletResult.firstNoteId;
983
1034
  }
984
1035
  // Process slur ends first (to close any pending slurs from before this tuplet)
@@ -1014,8 +1065,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1014
1065
  if (ctx.clef && ctx.clef !== currentClef) {
1015
1066
  const layerStaff = voice.staff || 1;
1016
1067
  if (currentStaff === layerStaff) {
1017
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
1018
- xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
1068
+ xml += `${currentIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
1019
1069
  }
1020
1070
  currentClef = ctx.clef;
1021
1071
  }
@@ -1095,6 +1145,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1095
1145
  }
1096
1146
  }
1097
1147
  break;
1148
+ case 'dynamic':
1149
+ {
1150
+ // Standalone (leading) dynamic - attaches to the following note
1151
+ const dynEvent = event;
1152
+ pendingDynamics.push(dynEvent.dynamicType);
1153
+ }
1154
+ break;
1098
1155
  }
1099
1156
  // Close beam element if beam ends
1100
1157
  if (beamEnd) {
@@ -1580,8 +1637,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1580
1637
  for (let ls = 1; ls <= info.maxStaff; ls++) {
1581
1638
  const globalStaff = info.staffOffset + ls;
1582
1639
  const clef = info.clefs[ls] || Clef.treble;
1583
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1584
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1640
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1585
1641
  }
1586
1642
  xml += `${indent} </staffGrp>\n`;
1587
1643
  }
@@ -1589,8 +1645,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1589
1645
  // Single staff part
1590
1646
  const globalStaff = info.staffOffset + 1;
1591
1647
  const clef = info.clefs[1] || Clef.treble;
1592
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1593
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1648
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1594
1649
  }
1595
1650
  }
1596
1651
  xml += `${indent} </staffGrp>\n`;
@@ -286,7 +286,7 @@ const serializeContextChange = (event) => {
286
286
  const parts = [];
287
287
  // Clef
288
288
  if (event.clef) {
289
- parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
289
+ parts.push('\\clef "' + (CLEF_MAP[event.clef] ?? event.clef) + '"');
290
290
  }
291
291
  // Key signature
292
292
  if (event.key) {
@@ -455,12 +455,25 @@ const serializeEvent = (event, env, prevDuration) => {
455
455
  return serializeTremoloEvent(event, env);
456
456
  case 'barline':
457
457
  return { str: serializeBarlineEvent(event), newEnv: env };
458
+ case 'markup': {
459
+ const mk = event;
460
+ const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
461
+ return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
462
+ }
463
+ case 'dynamic': {
464
+ const dynStr = DYNAMIC_MAP[event.dynamicType];
465
+ return { str: dynStr || '', newEnv: env };
466
+ }
458
467
  default:
459
468
  return { str: '', newEnv: env };
460
469
  }
461
470
  };
462
- // Find first clef in voice events
471
+ // Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
472
+ // a clef context event that appears before any musical event on the home staff.
473
+ // A clef that first appears AFTER a note is a mid-voice change and must be emitted
474
+ // inline where it occurs, not hoisted to the front — so it is not returned here.
463
475
  const findVoiceClef = (voice) => {
476
+ const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
464
477
  let activeStaff = voice.staff;
465
478
  for (const event of voice.events) {
466
479
  if (event.type === 'context') {
@@ -471,6 +484,11 @@ const findVoiceClef = (voice) => {
471
484
  return ctx.clef;
472
485
  }
473
486
  }
487
+ else if (MUSICAL.has(event.type)) {
488
+ // Reached music on the home staff before any clef — no leading clef.
489
+ if (activeStaff === voice.staff)
490
+ return undefined;
491
+ }
474
492
  }
475
493
  return undefined;
476
494
  };
@@ -490,7 +508,6 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
490
508
  // before any music collapse to the last one (earlier ones are no-ops).
491
509
  // leadStaffScanEnd is the index of the first event that ends this scan —
492
510
  // context{staff} events before this index are skipped in the main loop.
493
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
494
511
  let effectiveInitialStaff = voice.staff;
495
512
  let leadStaffScanEnd = 0;
496
513
  for (let i = 0; i < voice.events.length; i++) {
@@ -516,8 +533,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
516
533
  leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
517
534
  continue;
518
535
  }
519
- if (MUSICAL_TYPES.has(e.type))
520
- break;
536
+ // Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
537
+ // visible position; a staff switch after it must not be hoisted ahead of it.
538
+ break;
521
539
  }
522
540
  // Output staff command if voice staff differs from current parser staff,
523
541
  // or always output if it's a grand staff score for clarity.
@@ -545,16 +563,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
545
563
  parts.push('\\time ' + numerator + '/' + denominator);
546
564
  }
547
565
  }
548
- // Output clef only if not yet emitted or changed for this staff
549
- const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
566
+ // Output clef only if not yet emitted or changed for this staff.
567
+ // Prefer this voice's leading clef (a clef before any music); fall back to the
568
+ // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
569
+ // hoisted here — it is emitted inline at its position.
570
+ const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
550
571
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
551
572
  if (voiceClef && !clefAlreadyEmitted) {
552
- parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
573
+ parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
553
574
  if (emittedClefs)
554
575
  emittedClefs[voice.staff] = voiceClef;
555
576
  }
556
- // Skip redundant clef context events if this staff's clef is already established
557
- const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
558
577
  let activeStaff = effectiveInitialStaff;
559
578
  let activeStemDir;
560
579
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
@@ -572,7 +591,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
572
591
  // Emit target staff clef if the event carries one or allStaffClefs knows it
573
592
  const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
574
593
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
575
- parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
594
+ parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
576
595
  if (emittedClefs)
577
596
  emittedClefs[activeStaff] = ctxClef;
578
597
  }
@@ -581,8 +600,11 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
581
600
  if (ctx.staff && !ctx.clef && !ctx.ottava)
582
601
  continue; // same staff, pure no-op
583
602
  if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
584
- // Skip clef-only context events if clef already established for this staff
585
- if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
603
+ // Skip a clef-only context event only if it is REDUNDANT i.e. it restates the
604
+ // clef already active for this staff. A clef that differs is a genuine change and
605
+ // must be emitted inline at its position.
606
+ if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
607
+ emittedClefs?.[ctx.staff || activeStaff] === ctx.clef) {
586
608
  continue;
587
609
  }
588
610
  }
@@ -596,7 +618,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
596
618
  // Emit the target staff's clef if it differs from what was last emitted for this staff
597
619
  const targetClef = allStaffClefs?.[activeStaff];
598
620
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
599
- parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
621
+ parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
600
622
  if (emittedClefs)
601
623
  emittedClefs[activeStaff] = targetClef;
602
624
  }
@@ -651,11 +673,15 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
651
673
  }
652
674
  const voiceStrs = [];
653
675
  let staff = currentStaff;
676
+ // A part is a grand staff only if its voices span more than one staff.
677
+ // Only then do we force \staff on every voice; single-staff parts emit \staff
678
+ // solely when the staff actually changes (e.g. resetting after a prior grand staff).
679
+ const partIsGrandStaff = new Set(part.voices.map(v => v.staff)).size > 1;
654
680
  for (let i = 0; i < part.voices.length; i++) {
655
681
  const voice = part.voices[i];
656
682
  // Pass measureContext to all voices, isFirstVoice for key/time
657
683
  const isFirstVoice = isFirstPart && i === 0;
658
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
684
+ const { str, newStaff } = serializeVoice(voice, staff, partIsGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
659
685
  voiceStrs.push(str);
660
686
  staff = newStaff;
661
687
  }
@@ -664,7 +690,7 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
664
690
  };
665
691
  // Serialize a measure, tracking staff state across parts
666
692
  // Always output key/time at start of each measure
667
- const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs, emittedClefs) => {
693
+ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, partStaffClefs, partEmittedClefs) => {
668
694
  const parts = [];
669
695
  // Build measure context for all voices (key/time)
670
696
  // Key and time are written to first voice, clef to all voices based on staff
@@ -673,12 +699,14 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
673
699
  key: currentKey,
674
700
  time: currentTime,
675
701
  };
676
- // Pass staffClefs to parts for per-voice clef lookup
677
- const clefsByStaff = staffClefs || {};
702
+ // Per-part clef state: each part has its own staff→clef maps so that distinct
703
+ // parts sharing staff number 1 do not clobber each other's clefs.
704
+ const clefsFor = (pi) => partStaffClefs?.[pi] || {};
705
+ const emittedFor = (pi) => partEmittedClefs?.[pi] || (partEmittedClefs ? (partEmittedClefs[pi] = {}) : {});
678
706
  // Parts
679
707
  let staff = currentStaff;
680
708
  if (measure.parts.length === 1) {
681
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
709
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsFor(0), emittedFor(0));
682
710
  if (partStr) {
683
711
  parts.push(partStr);
684
712
  }
@@ -690,7 +718,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
690
718
  for (let i = 0; i < measure.parts.length; i++) {
691
719
  const part = measure.parts[i];
692
720
  // Pass measureContext to all parts, isFirstPart to first part only
693
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
721
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsFor(i), emittedFor(i));
694
722
  if (str) {
695
723
  partStrs.push(str);
696
724
  }
@@ -722,6 +750,12 @@ const serializeMetadata = (metadata) => {
722
750
  if (metadata.lyricist) {
723
751
  lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
724
752
  }
753
+ if (metadata.genre) {
754
+ lines.push('[genre "' + escapeString(metadata.genre) + '"]');
755
+ }
756
+ if (metadata.instrument) {
757
+ lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
758
+ }
725
759
  if (metadata.autoBeam) {
726
760
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
727
761
  }
@@ -749,8 +783,11 @@ export const serializeLilyletDoc = (doc) => {
749
783
  let currentStaff = 1; // Parser starts at staff 1
750
784
  let currentKey;
751
785
  let currentTime;
752
- const staffClefs = {}; // Track clef per staff
753
- const emittedClefs = {}; // Track which clefs have been output
786
+ // Clefs are tracked per part (each part is an independent instrument). Voice `staff`
787
+ // numbers are staff-within-part, so distinct parts may both use staff 1 — keying clef
788
+ // state by staff alone would conflate them. Outer key = part index, inner key = staff.
789
+ const partStaffClefs = {};
790
+ const partEmittedClefs = {};
754
791
  for (let i = 0; i < doc.measures.length; i++) {
755
792
  const measure = doc.measures[i];
756
793
  // Update current key/time if measure has them
@@ -760,24 +797,31 @@ export const serializeLilyletDoc = (doc) => {
760
797
  if (measure.timeSig) {
761
798
  currentTime = measure.timeSig;
762
799
  }
763
- // Collect clefs from this measure's voices
764
- for (const part of measure.parts) {
765
- for (const voice of part.voices) {
766
- let clefActiveStaff = voice.staff;
767
- for (const event of voice.events) {
768
- if (event.type === 'context') {
769
- const ctx = event;
770
- if (ctx.staff) {
771
- clefActiveStaff = ctx.staff;
772
- }
773
- if (ctx.clef) {
774
- staffClefs[clefActiveStaff] = ctx.clef;
800
+ // Collect clefs from this measure's voices, per part — but only AFTER serializing
801
+ // the measure, so that during serialization allStaffClefs reflects the clef state
802
+ // CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
803
+ // to the measure's front; it is emitted inline at its position).
804
+ const collectClefs = () => {
805
+ measure.parts.forEach((part, pi) => {
806
+ const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
807
+ for (const voice of part.voices) {
808
+ let clefActiveStaff = voice.staff;
809
+ for (const event of voice.events) {
810
+ if (event.type === 'context') {
811
+ const ctx = event;
812
+ if (ctx.staff) {
813
+ clefActiveStaff = ctx.staff;
814
+ }
815
+ if (ctx.clef) {
816
+ staffClefs[clefActiveStaff] = ctx.clef;
817
+ }
775
818
  }
776
819
  }
777
820
  }
778
- }
779
- }
780
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
821
+ });
822
+ };
823
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
824
+ collectClefs();
781
825
  // Always include measure, even if empty (use space rest for empty measures)
782
826
  measureStrs.push(measureStr || 's1');
783
827
  currentStaff = newStaff;
@@ -221,7 +221,11 @@ export interface MarkupEvent {
221
221
  content: string;
222
222
  placement?: Placement;
223
223
  }
224
- export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
224
+ export interface DynamicEvent {
225
+ type: 'dynamic';
226
+ dynamicType: DynamicType;
227
+ }
228
+ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
225
229
  export interface Voice {
226
230
  staff: number;
227
231
  events: Event[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
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",
@@ -30,12 +30,14 @@
30
30
  "test": "tsx ./tests/parser.ts",
31
31
  "test:mei": "tsx ./tests/mei.ts",
32
32
  "test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
33
- "test:unit": "tsx ./tests/unit/encodePitch.test.ts",
34
33
  "test:partial": "tsx ./tests/unit/partialWarning.test.ts",
35
34
  "test:decoder": "tsx ./tests/lilypondDecoder.ts",
36
35
  "test:abc": "tsx ./tests/abc-decoder.ts",
37
36
  "test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
38
37
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
38
+ "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
39
+ "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
40
+ "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
39
41
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
40
42
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
41
43
  "ts": "tsx"
@@ -229,6 +229,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
229
229
  <key_signature>"treble" return 'TREBLE';
230
230
  <key_signature>"bass" return 'BASS';
231
231
  <key_signature>"tenor" return 'TENOR';
232
+ <key_signature>"alto" return 'ALTO';
232
233
  <key_signature>"none" return 'NAME';
233
234
  <key_signature>"Dor" return 'NAME';
234
235
  <key_signature>"Phr" return 'NAME';
@@ -238,7 +239,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
238
239
  <key_signature>"Loc" return 'NAME';
239
240
  <key_signature>"HP" return 'NAME';
240
241
  <key_signature>"Hp" return 'NAME';
241
- <key_signature>[a-z]+[ \t]*=[^\n\]]* {}
242
+ <key_signature>[a-z]+[ \t]*[=][^\n\]]* {}
242
243
  <key_signature>[A-G] return 'A';
243
244
  <key_signature>[A-Z][a-z]+ return 'NAME';
244
245
  <key_signature>[b] return 'FLAT';
@@ -256,14 +257,16 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
256
257
  <comment>[^\n]+ { return 'COMMENT'; }
257
258
  <comment>\n { this.popState(); }
258
259
  <spec_comment_name>[ \t]+ {}
259
- <spec_comment_name>"score" { this.popState(); this.pushState('spec_comment'); return 'SCORE'; }
260
- <spec_comment_name>"staves" { this.popState(); this.pushState('spec_comment'); return 'SCORE'; }
260
+ <spec_comment_name>"score" { this.popState(); this.pushState('spec_comment'); this._scoreDepth = 0; return 'SCORE'; }
261
+ <spec_comment_name>"staves" { this.popState(); this.pushState('spec_comment'); this._scoreDepth = 0; return 'SCORE'; }
261
262
  <spec_comment_name>[\w]+ { this.popState(); this.pushState('spec_comment_skip'); }
262
263
  <spec_comment_name>\n { this.popState(); this.popState(); }
263
264
  <spec_comment>[ \t]+ {}
264
- <spec_comment>[(){}\[\]|] return yytext
265
+ <spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
266
+ <spec_comment>[)\]}] { this._scoreDepth = (this._scoreDepth || 0) - 1; return yytext; }
267
+ <spec_comment>[|] return yytext
265
268
  <spec_comment>[\w]+ return 'NN'
266
- <spec_comment>\n { this.popState(); this.popState(); return 'LAYOUT_END'; }
269
+ <spec_comment>\n { if (this._scoreDepth > 0) { /* layout continues on next line */ } else { this.popState(); this.popState(); return 'LAYOUT_END'; } }
267
270
  <spec_comment_skip>[^\n]+ {}
268
271
  <spec_comment_skip>\n { this.popState(); this.popState(); }
269
272
 
@@ -399,6 +402,7 @@ staff_shift
399
402
  key_signature
400
403
  : key_root -> $1
401
404
  | NAME -> key(null, $1)
405
+ | clef -> $1
402
406
  ;
403
407
 
404
408
  key_root
@@ -412,6 +416,11 @@ clef
412
416
  : TREBLE -> clef($1)
413
417
  | BASS -> clef($1)
414
418
  | TENOR -> clef($1)
419
+ | ALTO -> clef($1)
420
+ | TREBLE plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
421
+ | BASS plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
422
+ | TENOR plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
423
+ | ALTO plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
415
424
  ;
416
425
 
417
426
  sharp_or_flat
@@ -460,8 +469,11 @@ voice_exp
460
469
  : number -> voice($1)
461
470
  | number NAME -> voice($1, $2)
462
471
  | number NAME assigns -> voice($1, $2, $3)
472
+ | number NAME plus_minus_number -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3))
473
+ | number NAME plus_minus_number assigns -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3), $4)
463
474
  | NAME -> voice(1, $1)
464
475
  | NAME assigns -> voice(1, $1, $2)
476
+ | NAME plus_minus_number assigns -> voice(1, $1, $3)
465
477
  | upper_phonet number -> voice(1, $1 + String($2))
466
478
  | upper_phonet number assigns -> voice(1, $1 + String($2), $3)
467
479
  | upper_phonet number NAME -> voice(1, $1 + String($2))
@@ -524,6 +536,7 @@ bar
524
536
  | ':' '|' '|' ':' -> ':|:'
525
537
  | '|' '|' -> '||'
526
538
  | '|' ']' -> '|]'
539
+ | '|' ']' ':' -> '|]'
527
540
  | ':' '|' ']' -> ':|]'
528
541
  | '|' N -> '|' + $2
529
542
  | ':' '|' N -> ':|' + $2
@@ -542,7 +555,6 @@ music
542
555
  | music N -> $1
543
556
  | music NAME -> $1
544
557
  | music '^' NAME -> $1
545
- | music '^' -> $1
546
558
  | music '[' N -> $1
547
559
  ;
548
560
 
@@ -586,6 +598,8 @@ articulation_content
586
598
  | a -> articulation($1)
587
599
  | "^" -> articulation($1)
588
600
  | fingering_numbers -> ({fingering: Number($1)})
601
+ | '(' fingering_numbers ')' -> ({fingering: Number($2)})
602
+ | '(' fingering_numbers -> ({fingering: Number($2)})
589
603
  | tremolo -> ({tremolo: $1})
590
604
  | tremolo '-' -> ({tremolo: $1}) // unknown meaning of '-'?
591
605
  ;