@k-l-lambda/lilylet 0.1.34 → 0.1.36

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,7 @@ import {
16
16
  ContextChange,
17
17
  TupletEvent,
18
18
  TremoloEvent,
19
+ BarlineEvent,
19
20
  Pitch,
20
21
  Duration,
21
22
  Mark,
@@ -524,6 +525,16 @@ const serializeTremoloEvent = (
524
525
  };
525
526
 
526
527
 
528
+ // Serialize a barline event
529
+ const serializeBarlineEvent = (event: BarlineEvent): string => {
530
+ // Only output non-default barlines
531
+ if (event.style && event.style !== '|') {
532
+ return '\\bar "' + event.style + '"';
533
+ }
534
+ return '';
535
+ };
536
+
537
+
527
538
  // Serialize a single event with pitch environment tracking
528
539
  const serializeEvent = (
529
540
  event: Event,
@@ -541,27 +552,46 @@ const serializeEvent = (
541
552
  return serializeTupletEvent(event as TupletEvent, env);
542
553
  case 'tremolo':
543
554
  return serializeTremoloEvent(event as TremoloEvent, env);
555
+ case 'barline':
556
+ return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
544
557
  default:
545
558
  return { str: '', newEnv: env };
546
559
  }
547
560
  };
548
561
 
549
562
 
550
- // Key/time signature info to inject into first voice
563
+ // Key/time/clef signature info to inject into voices
551
564
  interface MeasureContext {
552
565
  key?: KeySignature;
553
- time?: { numerator: number; denominator: number };
566
+ time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
567
+ clef?: Clef;
554
568
  }
555
569
 
570
+ // Find first clef in voice events
571
+ const findVoiceClef = (voice: Voice): Clef | undefined => {
572
+ for (const event of voice.events) {
573
+ if (event.type === 'context') {
574
+ const ctx = event as ContextChange;
575
+ if (ctx.clef) {
576
+ return ctx.clef;
577
+ }
578
+ }
579
+ }
580
+ return undefined;
581
+ };
582
+
556
583
  // Serialize a voice with pitch environment tracking
557
584
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
558
585
  // If isGrandStaff is true, always output \staff command for clarity
559
- // If measureContext is provided, output key/time after \staff (for first voice only)
586
+ // measureContext provides key/time for first voice
587
+ // staffClef is the clef for this voice's staff (tracked across measures)
560
588
  const serializeVoice = (
561
589
  voice: Voice,
562
590
  currentStaff: number,
563
591
  isGrandStaff: boolean = false,
564
- measureContext?: MeasureContext
592
+ measureContext?: MeasureContext,
593
+ isFirstVoice: boolean = false,
594
+ staffClef?: Clef
565
595
  ): { str: string; newStaff: number } => {
566
596
  const parts: string[] = [];
567
597
  let prevDuration: Duration | undefined;
@@ -574,8 +604,8 @@ const serializeVoice = (
574
604
  parts.push('\\staff "' + voice.staff + '"');
575
605
  }
576
606
 
577
- // Output key/time signatures after \staff (for first voice of first measure)
578
- if (measureContext) {
607
+ // Output key/time signatures after \staff (for first voice only)
608
+ if (measureContext && isFirstVoice) {
579
609
  if (measureContext.key) {
580
610
  let keyStr = String(measureContext.key.pitch);
581
611
  if (measureContext.key.accidental) {
@@ -585,11 +615,35 @@ const serializeVoice = (
585
615
  parts.push('\\key ' + keyStr);
586
616
  }
587
617
  if (measureContext.time) {
588
- parts.push('\\time ' + measureContext.time.numerator + '/' + measureContext.time.denominator);
618
+ const { numerator, denominator, symbol } = measureContext.time;
619
+ // Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
620
+ // (meaning numeric display was explicitly requested)
621
+ if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
622
+ parts.push('\\numericTimeSignature');
623
+ }
624
+ parts.push('\\time ' + numerator + '/' + denominator);
589
625
  }
590
626
  }
591
627
 
628
+ // Output clef for every voice (use staff clef tracked across measures, or find from voice events)
629
+ const voiceClef = staffClef || findVoiceClef(voice);
630
+ if (voiceClef) {
631
+ parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
632
+ }
633
+
634
+ // Track if we've already output the clef to avoid duplication
635
+ let clefOutputted = !!voiceClef;
636
+
592
637
  for (const event of voice.events) {
638
+ // Skip clef context events if we've already output the clef at the beginning
639
+ if (clefOutputted && event.type === 'context') {
640
+ const ctx = event as ContextChange;
641
+ if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
642
+ // This is a clef-only context event, skip it
643
+ continue;
644
+ }
645
+ }
646
+
593
647
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
594
648
  pitchEnv = newEnv;
595
649
 
@@ -610,12 +664,14 @@ const serializeVoice = (
610
664
 
611
665
 
612
666
  // Serialize a part, tracking staff state across voices
613
- // If measureContext is provided, pass it to the first voice only
667
+ // measureContext is passed to all voices (for clef), but key/time only to first voice
614
668
  const serializePart = (
615
669
  part: Part,
616
670
  currentStaff: number,
617
671
  isGrandStaff: boolean = false,
618
- measureContext?: MeasureContext
672
+ measureContext?: MeasureContext,
673
+ isFirstPart: boolean = false,
674
+ clefsByStaff?: Record<number, Clef>
619
675
  ): { str: string; newStaff: number } => {
620
676
  if (part.voices.length === 0) {
621
677
  return { str: '', newStaff: currentStaff };
@@ -626,9 +682,11 @@ const serializePart = (
626
682
 
627
683
  for (let i = 0; i < part.voices.length; i++) {
628
684
  const voice = part.voices[i];
629
- // Only pass measureContext to first voice
630
- const ctx = i === 0 ? measureContext : undefined;
631
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, ctx);
685
+ // Pass measureContext to all voices, isFirstVoice for key/time
686
+ // Pass staff clef from clefsByStaff map
687
+ const isFirstVoice = isFirstPart && i === 0;
688
+ const staffClef = clefsByStaff?.[voice.staff];
689
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
632
690
  voiceStrs.push(str);
633
691
  staff = newStaff;
634
692
  }
@@ -639,19 +697,33 @@ const serializePart = (
639
697
 
640
698
 
641
699
  // Serialize a measure, tracking staff state across parts
642
- const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: number, isGrandStaff: boolean = false): { str: string; newStaff: number } => {
700
+ // Always output key/time at start of each measure
701
+ const serializeMeasure = (
702
+ measure: Measure,
703
+ _isFirst: boolean,
704
+ currentStaff: number,
705
+ isGrandStaff: boolean = false,
706
+ currentKey?: KeySignature,
707
+ currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
708
+ staffClefs?: Record<number, Clef>
709
+ ): { str: string; newStaff: number } => {
643
710
  const parts: string[] = [];
644
711
 
645
- // Build measure context for first voice (key/time signatures)
646
- const measureContext: MeasureContext | undefined = isFirst ? {
647
- key: measure.key,
648
- time: measure.timeSig,
649
- } : undefined;
712
+ // Build measure context for all voices (key/time)
713
+ // Key and time are written to first voice, clef to all voices based on staff
714
+ // Use passed currentKey/currentTime which tracks across all measures
715
+ const measureContext: MeasureContext = {
716
+ key: currentKey,
717
+ time: currentTime,
718
+ };
719
+
720
+ // Pass staffClefs to parts for per-voice clef lookup
721
+ const clefsByStaff = staffClefs || {};
650
722
 
651
723
  // Parts
652
724
  let staff = currentStaff;
653
725
  if (measure.parts.length === 1) {
654
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
726
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
655
727
  if (partStr) {
656
728
  parts.push(partStr);
657
729
  }
@@ -661,9 +733,8 @@ const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: numb
661
733
  const partStrs: string[] = [];
662
734
  for (let i = 0; i < measure.parts.length; i++) {
663
735
  const part = measure.parts[i];
664
- // Only pass measureContext to first part
665
- const ctx = i === 0 ? measureContext : undefined;
666
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
736
+ // Pass measureContext to all parts, isFirstPart to first part only
737
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
667
738
  if (str) {
668
739
  partStrs.push(str);
669
740
  }
@@ -729,10 +800,35 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
729
800
 
730
801
  // Measures with bar lines, measure numbers, and double newlines
731
802
  // Track staff state across measures (parser remembers staff across bar lines)
803
+ // Track key/time/clef across measures to output in every measure
732
804
  const measureStrs: string[] = [];
733
805
  let currentStaff = 1; // Parser starts at staff 1
806
+ let currentKey: KeySignature | undefined;
807
+ let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
808
+ const staffClefs: Record<number, Clef> = {}; // Track clef per staff
809
+
734
810
  for (let i = 0; i < doc.measures.length; i++) {
735
- const { str: measureStr, newStaff } = serializeMeasure(doc.measures[i], i === 0, currentStaff, isGrandStaff);
811
+ const measure = doc.measures[i];
812
+ // Update current key/time if measure has them
813
+ if (measure.key) {
814
+ currentKey = measure.key;
815
+ }
816
+ if (measure.timeSig) {
817
+ currentTime = measure.timeSig;
818
+ }
819
+
820
+ // Collect clefs from this measure's voices
821
+ for (const part of measure.parts) {
822
+ for (const voice of part.voices) {
823
+ for (const event of voice.events) {
824
+ if (event.type === 'context' && (event as ContextChange).clef) {
825
+ staffClefs[voice.staff] = (event as ContextChange).clef!;
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
736
832
  // Always include measure, even if empty (use space rest for empty measures)
737
833
  measureStrs.push(measureStr || 's1');
738
834
  currentStaff = newStaff;
@@ -99,6 +99,12 @@ export interface Fraction {
99
99
  denominator: number;
100
100
  }
101
101
 
102
+ // Time signature with optional symbol display
103
+ // symbol: 'common' for C (4/4), 'cut' for C| (2/2), undefined for numeric
104
+ export interface TimeSig extends Fraction {
105
+ symbol?: 'common' | 'cut';
106
+ }
107
+
102
108
  export interface Pitch {
103
109
  phonet: Phonet;
104
110
  accidental?: Accidental;
@@ -292,7 +298,7 @@ export interface Part {
292
298
  // Measure contains parts separated by \\\
293
299
  export interface Measure {
294
300
  key?: KeySignature;
295
- timeSig?: Fraction;
301
+ timeSig?: TimeSig;
296
302
  parts: Part[];
297
303
  partial?: boolean;
298
304
  }
@@ -1,28 +0,0 @@
1
- /**
2
- * LilyPond to Lilylet Decoder
3
- *
4
- * Converts LilyPond notation files to Lilylet document format using the lotus parser.
5
- * This module is browser-compatible - it uses pre-compiled parser from lotus.
6
- */
7
- import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
8
- import { LilyletDoc, Event, Fraction } from "./types";
9
- interface ParsedMeasure {
10
- key: number | null;
11
- timeSig: Fraction | null;
12
- voices: ParsedVoice[];
13
- partial: boolean;
14
- }
15
- interface ParsedVoice {
16
- staff: number;
17
- events: Event[];
18
- }
19
- declare const parseLilyDocument: (lilyDocument: lilyParser.LilyDocument) => ParsedMeasure[];
20
- /**
21
- * Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
22
- */
23
- declare const decode: (lilypondSource: string) => LilyletDoc;
24
- /**
25
- * Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
26
- */
27
- declare const decodeFromDocument: (lilyDocument: lilyParser.LilyDocument) => LilyletDoc;
28
- export { decode, decodeFromDocument, parseLilyDocument, };