@k-l-lambda/lilylet 0.1.62 → 0.1.63

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.
@@ -758,13 +758,13 @@ const convertEventTerm = (
758
758
  const firstPitch = chord.pitches[0];
759
759
 
760
760
  // Check if rest
761
- if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x") {
761
+ if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x" || firstPitch.phonet === "y") {
762
762
  const duration = convertDuration(eventData.duration, unitLength);
763
763
  const rest: RestEvent = {
764
764
  type: "rest",
765
765
  duration,
766
766
  };
767
- if (firstPitch.phonet === "x") {
767
+ if (firstPitch.phonet === "x" || firstPitch.phonet === "y") {
768
768
  rest.invisible = true;
769
769
  }
770
770
  if (firstPitch.phonet === "Z") {
@@ -779,7 +779,7 @@ const convertEventTerm = (
779
779
 
780
780
  // Note or chord
781
781
  const pitches = chord.pitches.filter(p =>
782
- p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x"
782
+ p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y"
783
783
  ).map(convertPitch);
784
784
 
785
785
  if (pitches.length === 0) return undefined;
@@ -865,8 +865,17 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
865
865
  let timeSig: TimeSig | undefined;
866
866
  let keySig: KeySignature | undefined;
867
867
  let tempo: Tempo | undefined;
868
- const voiceConfigs = new Map<number, VoiceConfig>();
869
- const voiceClefs = new Map<number, Clef>();
868
+ const voiceConfigs = new Map<number | string, VoiceConfig>();
869
+ const voiceClefs = new Map<number | string, Clef>();
870
+
871
+ // Pre-scan for unit length (needed for bare Q: tempo)
872
+ for (const h of headers) {
873
+ const hdr = h as { name: string; value: any };
874
+ if (hdr.name === "L" && hdr.value?.numerator && hdr.value?.denominator) {
875
+ unitLength = hdr.value;
876
+ break;
877
+ }
878
+ }
870
879
 
871
880
  for (const h of headers) {
872
881
  if ((h as any).comment) continue;
@@ -905,24 +914,43 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
905
914
  const beatDuration = convertDuration(header.value.note, { numerator: 1, denominator: 1 });
906
915
  tempo = { beat: beatDuration, bpm: header.value.bpm };
907
916
  } else if (typeof header.value === "number") {
908
- tempo = { bpm: header.value };
917
+ const beat = convertDuration({ numerator: 1, denominator: 1 }, unitLength);
918
+ tempo = { beat, bpm: header.value };
909
919
  }
910
920
  break;
911
921
  case "V": {
912
922
  const voiceValue = header.value;
913
923
  if (voiceValue) {
914
- const voiceNum = typeof voiceValue === "number" ? voiceValue :
915
- (voiceValue.name || 1);
916
- const clefStr = typeof voiceValue === "string" ? voiceValue :
917
- (voiceValue.clef || undefined);
918
- voiceConfigs.set(voiceNum, {
919
- name: voiceNum,
924
+ let voiceId: number | string;
925
+ let clefStr: string | undefined;
926
+
927
+ if (typeof voiceValue === "number") {
928
+ voiceId = voiceValue;
929
+ } else if (typeof voiceValue === "string") {
930
+ voiceId = voiceValue;
931
+ } else {
932
+ const rawClef = (voiceValue.clef || "").replace(/,+$/, "").trim();
933
+ const isKnownClef = !!convertClef(rawClef);
934
+ if (isKnownClef) {
935
+ // V:1 treble → voiceId=number, clef=treble
936
+ voiceId = voiceValue.name || 1;
937
+ clefStr = rawClef;
938
+ } else {
939
+ // V:S clef=treble → voiceId=voiceName, clef from properties
940
+ voiceId = rawClef || voiceValue.name || 1;
941
+ const propClef = (voiceValue.properties?.clef || "").replace(/,+$/, "").trim();
942
+ clefStr = propClef || undefined;
943
+ }
944
+ }
945
+
946
+ voiceConfigs.set(voiceId, {
947
+ name: typeof voiceId === "number" ? voiceId : 1,
920
948
  clef: clefStr,
921
- properties: voiceValue.properties,
949
+ properties: voiceValue?.properties,
922
950
  });
923
951
  if (clefStr) {
924
952
  const clef = convertClef(clefStr);
925
- if (clef) voiceClefs.set(voiceNum, clef);
953
+ if (clef) voiceClefs.set(voiceId, clef);
926
954
  }
927
955
  }
928
956
  break;
@@ -577,6 +577,8 @@ const encodeTupletEvent = (event: TupletEvent | TimesEvent, env: PitchEnv, lastD
577
577
  result += str + ' ';
578
578
  newEnv = ne;
579
579
  newDuration = nd;
580
+ } else if (subEvent.type === 'context') {
581
+ result += encodeContextChange(subEvent) + ' ';
580
582
  }
581
583
  }
582
584
 
@@ -655,6 +655,7 @@ interface TupletEventResult {
655
655
  mordents: MordentRef[];
656
656
  turns: TurnRef[];
657
657
  arpeggios: ArpegRef[];
658
+ endingClef?: string; // Updated clef name if changed inside the tuplet
658
659
  }
659
660
 
660
661
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
@@ -673,7 +674,7 @@ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
673
674
  };
674
675
 
675
676
  // Convert TupletEvent to MEI
676
- const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>): TupletEventResult => {
677
+ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>, currentClef?: string): TupletEventResult => {
677
678
  // LilyPond \times 2/3 means "multiply duration by 2/3"
678
679
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
679
680
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -701,6 +702,8 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
701
702
  // Handle internal beam groups: if notes have manual beam marks, respect them
702
703
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
703
704
  let beamOpen = false;
705
+ let activeClef = currentClef;
706
+ let endingClef: string | undefined;
704
707
 
705
708
  for (const e of event.events) {
706
709
  if (e.type === 'note') {
@@ -744,6 +747,19 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
744
747
  } else if (e.type === 'rest') {
745
748
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
746
749
  xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
750
+ } else if (e.type === 'context') {
751
+ const ctx = e as ContextChange;
752
+ if (ctx.clef && ctx.clef !== activeClef) {
753
+ const layerStaffNum = layerStaff || 1;
754
+ const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
755
+ if (effectiveStaffNum === layerStaffNum) {
756
+ const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
757
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
758
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
759
+ }
760
+ activeClef = ctx.clef;
761
+ endingClef = ctx.clef;
762
+ }
747
763
  }
748
764
  }
749
765
 
@@ -753,7 +769,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
753
769
  }
754
770
 
755
771
  xml += `${indent}</tuplet>\n`;
756
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
772
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
757
773
  };
758
774
 
759
775
 
@@ -949,7 +965,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
949
965
  const markOptions = extractMarkOptions((event as NoteEvent).marks);
950
966
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
951
967
  }
952
- if (event.type === 'tuplet') {
968
+ if (event.type === 'tuplet' || event.type === 'times') {
953
969
  const tuplet = event as TupletEvent;
954
970
  // If the tuplet has internal beam groups, don't report beam marks to the parent
955
971
  // so the parent won't wrap the tuplet in an external <beam>
@@ -1210,12 +1226,18 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1210
1226
  xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1211
1227
  break;
1212
1228
  }
1213
- case 'tuplet': {
1229
+ case 'tuplet':
1230
+ case 'times': {
1214
1231
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1215
1232
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1216
- const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
1233
+ const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1217
1234
  xml += tupletResult.xml;
1218
1235
 
1236
+ // Propagate clef change from inside the tuplet to the parent tracker
1237
+ if (tupletResult.endingClef) {
1238
+ currentClef = tupletResult.endingClef as Clef;
1239
+ }
1240
+
1219
1241
  // Flush any pending markups onto the first note of the tuplet
1220
1242
  if (tupletResult.firstNoteId) {
1221
1243
  flushPendingMarkups(tupletResult.firstNoteId);
@@ -1944,7 +1966,7 @@ const docHasBeamMarks = (doc: LilyletDoc): boolean => {
1944
1966
  if (m.markType === 'beam') return true;
1945
1967
  }
1946
1968
  }
1947
- } else if (event.type === 'tuplet') {
1969
+ } else if (event.type === 'tuplet' || event.type === 'times') {
1948
1970
  const tuplet = event as TupletEvent;
1949
1971
  for (const e of tuplet.events) {
1950
1972
  if (e.type === 'note') {
@@ -2119,7 +2141,7 @@ const applyAutoBeamToVoice = (events: Event[], beamGroups: number[]): void => {
2119
2141
  // Rests break beam groups
2120
2142
  flushRun();
2121
2143
  position += dur;
2122
- } else if (event.type === 'tuplet') {
2144
+ } else if (event.type === 'tuplet' || event.type === 'times') {
2123
2145
  const tuplet = event as TupletEvent;
2124
2146
  const ratio = tuplet.ratio; // LilyPond ratio: num/den
2125
2147
 
@@ -2321,7 +2343,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2321
2343
  for (const event of voice.events) {
2322
2344
  // Check for actual musical content (not just context changes or pitch resets)
2323
2345
  if (event.type === 'note' || event.type === 'rest' ||
2324
- event.type === 'tuplet' || event.type === 'tremolo') {
2346
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2325
2347
  return true;
2326
2348
  }
2327
2349
  }