@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.
- package/README.md +30 -25
- package/lib/grammar.jison.js +193 -153
- package/lib/meiEncoder.js +135 -28
- package/lib/musicXmlDecoder.js +4 -4
- package/lib/serializer.js +93 -21
- package/lib/types.d.ts +4 -1
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +193 -153
- package/source/lilylet/lilylet.jison +46 -7
- package/source/lilylet/meiEncoder.ts +152 -28
- package/source/lilylet/musicXmlDecoder.ts +4 -4
- package/source/lilylet/serializer.ts +119 -23
- package/source/lilylet/types.ts +7 -1
- package/lib/lilypondDecoder.d.ts +0 -28
- package/lib/lilypondDecoder.js +0 -645
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
630
|
-
|
|
631
|
-
const
|
|
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
|
-
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
//
|
|
665
|
-
const
|
|
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
|
|
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;
|
package/source/lilylet/types.ts
CHANGED
|
@@ -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?:
|
|
301
|
+
timeSig?: TimeSig;
|
|
296
302
|
parts: Part[];
|
|
297
303
|
partial?: boolean;
|
|
298
304
|
}
|
package/lib/lilypondDecoder.d.ts
DELETED
|
@@ -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, };
|