@k-l-lambda/lilylet 0.1.67 → 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.
- package/lib/abc/grammar.jison.js +73 -73
- package/lib/lilylet/abcDecoder.d.ts +12 -6
- package/lib/lilylet/abcDecoder.js +158 -36
- package/lib/lilylet/grammar.jison.js +195 -185
- package/lib/lilylet/meiEncoder.js +31 -6
- package/lib/lilylet/serializer.js +53 -25
- package/lib/lilylet/types.d.ts +5 -1
- package/package.json +1 -2
- package/source/abc/abc.jison +1 -2
- package/source/abc/grammar.jison.js +73 -73
- package/source/lilylet/abcDecoder.ts +169 -30
- package/source/lilylet/grammar.jison.js +195 -185
- package/source/lilylet/lilylet.jison +8 -0
- package/source/lilylet/meiEncoder.ts +36 -8
- package/source/lilylet/serializer.ts +55 -24
- package/source/lilylet/types.ts +6 -1
|
@@ -34,6 +34,9 @@ import {
|
|
|
34
34
|
NavigationMarkType,
|
|
35
35
|
Tempo,
|
|
36
36
|
BarlineEvent,
|
|
37
|
+
MarkupEvent,
|
|
38
|
+
DynamicEvent,
|
|
39
|
+
Placement,
|
|
37
40
|
} from "./types";
|
|
38
41
|
|
|
39
42
|
|
|
@@ -125,12 +128,25 @@ const convertAccidental = (acc: number | null): Accidental | undefined => {
|
|
|
125
128
|
}
|
|
126
129
|
};
|
|
127
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Pitch-conversion context for resolving ABC's implicit accidentals into lilylet's
|
|
133
|
+
* absolute pitch model. ABC applies the key signature and in-measure accidentals
|
|
134
|
+
* implicitly to bare note letters; lilylet note names carry their own accidental, so
|
|
135
|
+
* we resolve the effective accidental here.
|
|
136
|
+
*/
|
|
137
|
+
interface PitchContext {
|
|
138
|
+
keyAlterations: Map<Phonet, Accidental>; // letters altered by the active key signature
|
|
139
|
+
// In-measure accidental memory, keyed by "phonet:octave". An explicit accidental
|
|
140
|
+
// persists for the rest of the measure at that pitch/octave (standard notation rule).
|
|
141
|
+
measureAccidentals: Map<string, Accidental | "natural">;
|
|
142
|
+
}
|
|
143
|
+
|
|
128
144
|
/**
|
|
129
145
|
* Convert ABC pitch to Lilylet Pitch
|
|
130
146
|
* Uppercase C-B = octave 0, lowercase c-b = octave 1
|
|
131
147
|
* quotes (from ' and ,) add/subtract octaves
|
|
132
148
|
*/
|
|
133
|
-
const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
|
|
149
|
+
const convertPitch = (abcPitch: ABC.Pitch, ctx?: PitchContext): Pitch => {
|
|
134
150
|
const phonet = ABC_PHONET_MAP[abcPitch.phonet];
|
|
135
151
|
if (!phonet) {
|
|
136
152
|
throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
|
|
@@ -142,10 +158,36 @@ const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
|
|
|
142
158
|
const octave = baseOctave + (abcPitch.quotes || 0);
|
|
143
159
|
|
|
144
160
|
const pitch: Pitch = { phonet, octave };
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
|
|
162
|
+
// Resolve the effective accidental.
|
|
163
|
+
const explicit = convertAccidental(abcPitch.acc); // accidental written on this note
|
|
164
|
+
const hasExplicit = abcPitch.acc !== null && abcPitch.acc !== undefined;
|
|
165
|
+
|
|
166
|
+
if (ctx) {
|
|
167
|
+
const memKey = `${phonet}:${octave}`;
|
|
168
|
+
if (hasExplicit) {
|
|
169
|
+
// Explicit accidental (incl. natural) overrides and is remembered for the measure.
|
|
170
|
+
if (abcPitch.acc === 0) {
|
|
171
|
+
ctx.measureAccidentals.set(memKey, "natural");
|
|
172
|
+
// natural cancels the key alteration → no accidental on the lilylet pitch
|
|
173
|
+
} else if (explicit) {
|
|
174
|
+
ctx.measureAccidentals.set(memKey, explicit);
|
|
175
|
+
pitch.accidental = explicit;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// No explicit accidental: inherit from in-measure memory, else the key signature.
|
|
179
|
+
const remembered = ctx.measureAccidentals.get(memKey);
|
|
180
|
+
if (remembered !== undefined) {
|
|
181
|
+
if (remembered !== "natural") pitch.accidental = remembered;
|
|
182
|
+
} else {
|
|
183
|
+
const fromKey = ctx.keyAlterations.get(phonet);
|
|
184
|
+
if (fromKey) pitch.accidental = fromKey;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else if (explicit) {
|
|
188
|
+
pitch.accidental = explicit;
|
|
148
189
|
}
|
|
190
|
+
|
|
149
191
|
return pitch;
|
|
150
192
|
};
|
|
151
193
|
|
|
@@ -310,6 +352,42 @@ const convertKeySignature = (abcKey: ABC.KeySignature): KeySignature => {
|
|
|
310
352
|
};
|
|
311
353
|
};
|
|
312
354
|
|
|
355
|
+
// Circle-of-fifths: a lilylet key (pitch+accidental+mode) → set of letters the key
|
|
356
|
+
// signature alters, and the direction (sharp/flat). ABC note letters inherit these
|
|
357
|
+
// alterations implicitly (e.g. K:Eb makes B sound as Bb); lilylet pitches are absolute,
|
|
358
|
+
// so the decoder must bake the alteration into each pitch's accidental.
|
|
359
|
+
const SHARP_ORDER: Phonet[] = [Phonet.f, Phonet.c, Phonet.g, Phonet.d, Phonet.a, Phonet.e, Phonet.b];
|
|
360
|
+
const FLAT_ORDER: Phonet[] = [Phonet.b, Phonet.e, Phonet.a, Phonet.d, Phonet.g, Phonet.c, Phonet.f];
|
|
361
|
+
|
|
362
|
+
const KEY_NAME_TO_FIFTHS: Record<string, number> = {
|
|
363
|
+
c: 0, g: 1, d: 2, a: 3, e: 4, b: 5, "f#": 6, "c#": 7,
|
|
364
|
+
f: -1, bb: -2, eb: -3, ab: -4, db: -5, gb: -6, cb: -7,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Compute the number of sharps/flats (fifths) for a resolved KeySignature.
|
|
368
|
+
const keySignatureFifths = (key: KeySignature): number => {
|
|
369
|
+
let letter = key.pitch as string;
|
|
370
|
+
if (key.accidental === Accidental.sharp) letter += "#";
|
|
371
|
+
else if (key.accidental === Accidental.flat) letter += "b";
|
|
372
|
+
let fifths = KEY_NAME_TO_FIFTHS[letter];
|
|
373
|
+
if (fifths === undefined) fifths = 0;
|
|
374
|
+
// Minor keys share the fifths of their relative major (3 fifths up).
|
|
375
|
+
if (key.mode === "minor") fifths += 3;
|
|
376
|
+
return fifths;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// Map of phonet → accidental implied by the key signature (only altered letters present).
|
|
380
|
+
const keySignatureAlterations = (key: KeySignature): Map<Phonet, Accidental> => {
|
|
381
|
+
const fifths = keySignatureFifths(key);
|
|
382
|
+
const map = new Map<Phonet, Accidental>();
|
|
383
|
+
if (fifths > 0) {
|
|
384
|
+
for (let i = 0; i < fifths && i < SHARP_ORDER.length; i++) map.set(SHARP_ORDER[i], Accidental.sharp);
|
|
385
|
+
} else if (fifths < 0) {
|
|
386
|
+
for (let i = 0; i < -fifths && i < FLAT_ORDER.length; i++) map.set(FLAT_ORDER[i], Accidental.flat);
|
|
387
|
+
}
|
|
388
|
+
return map;
|
|
389
|
+
};
|
|
390
|
+
|
|
313
391
|
/**
|
|
314
392
|
* Convert ABC clef string to Lilylet Clef
|
|
315
393
|
*/
|
|
@@ -567,7 +645,8 @@ interface VoiceConfig {
|
|
|
567
645
|
const processBarPatch = (
|
|
568
646
|
patch: ABC.BarPatch,
|
|
569
647
|
unitLength: { numerator: number; denominator: number },
|
|
570
|
-
slurDepth: { count: number }
|
|
648
|
+
slurDepth: { count: number },
|
|
649
|
+
pitchCtx?: PitchContext
|
|
571
650
|
): { events: Event[]; barline?: string } => {
|
|
572
651
|
const events: Event[] = [];
|
|
573
652
|
const terms = patch.terms || [];
|
|
@@ -577,6 +656,11 @@ const processBarPatch = (
|
|
|
577
656
|
// Collect all events first, then handle broken rhythms and tuplets
|
|
578
657
|
const rawNoteRests: { event: NoteEvent | RestEvent; index: number; broken?: number }[] = [];
|
|
579
658
|
|
|
659
|
+
// A broken-rhythm marker (>, <) belongs to the LEFT note but modifies the pair
|
|
660
|
+
// (left, right). We can only apply it once the right note has been pushed, so we
|
|
661
|
+
// stash it here and resolve it when the next note/rest arrives.
|
|
662
|
+
let pendingBroken: number | null = null;
|
|
663
|
+
|
|
580
664
|
let i = 0;
|
|
581
665
|
while (i < terms.length) {
|
|
582
666
|
const term = terms[i];
|
|
@@ -591,10 +675,18 @@ const processBarPatch = (
|
|
|
591
675
|
events.push({ type: "context", clef } as ContextChange);
|
|
592
676
|
}
|
|
593
677
|
} else if (ctrl.value?.root) {
|
|
678
|
+
const newKey = convertKeySignature(ctrl.value);
|
|
594
679
|
events.push({
|
|
595
680
|
type: "context",
|
|
596
|
-
key:
|
|
681
|
+
key: newKey,
|
|
597
682
|
} as ContextChange);
|
|
683
|
+
// Update the active key alterations and clear in-measure accidentals.
|
|
684
|
+
if (pitchCtx) {
|
|
685
|
+
const alt = keySignatureAlterations(newKey);
|
|
686
|
+
pitchCtx.keyAlterations.clear();
|
|
687
|
+
for (const [k, v] of alt) pitchCtx.keyAlterations.set(k, v);
|
|
688
|
+
pitchCtx.measureAccidentals.clear();
|
|
689
|
+
}
|
|
598
690
|
}
|
|
599
691
|
} else if (ctrl.name === "M") {
|
|
600
692
|
if (ctrl.value?.numerator && ctrl.value?.denominator) {
|
|
@@ -632,7 +724,7 @@ const processBarPatch = (
|
|
|
632
724
|
while (j < terms.length && collected < r) {
|
|
633
725
|
const nextTerm = terms[j];
|
|
634
726
|
if ((nextTerm as ABC.EventTerm).event) {
|
|
635
|
-
const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges);
|
|
727
|
+
const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
|
|
636
728
|
if (evt) {
|
|
637
729
|
// Push any pending context changes before tuplet
|
|
638
730
|
for (const ctx of pendingContextChanges.splice(0)) {
|
|
@@ -692,10 +784,16 @@ const processBarPatch = (
|
|
|
692
784
|
// Text
|
|
693
785
|
if ((term as ABC.TextTerm).text !== undefined) {
|
|
694
786
|
const text = (term as ABC.TextTerm).text;
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
787
|
+
// ABC chord/annotation text: a leading ^ / _ marks placement above / below;
|
|
788
|
+
// other position prefixes (<,>,@) just denote placement we don't model, so strip.
|
|
789
|
+
let placement: Placement | undefined;
|
|
790
|
+
let content = text;
|
|
791
|
+
if (text.startsWith("^")) { placement = Placement.above; content = text.slice(1); }
|
|
792
|
+
else if (text.startsWith("_")) { placement = Placement.below; content = text.slice(1); }
|
|
793
|
+
else if (/^[<>@]/.test(text)) { content = text.slice(1); }
|
|
794
|
+
const markup: MarkupEvent = { type: "markup", content };
|
|
795
|
+
if (placement) markup.placement = placement;
|
|
796
|
+
events.push(markup);
|
|
699
797
|
i++;
|
|
700
798
|
continue;
|
|
701
799
|
}
|
|
@@ -709,7 +807,7 @@ const processBarPatch = (
|
|
|
709
807
|
events.push(ctx);
|
|
710
808
|
}
|
|
711
809
|
|
|
712
|
-
const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
|
|
810
|
+
const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
|
|
713
811
|
if (evt) {
|
|
714
812
|
if (Array.isArray(evt)) {
|
|
715
813
|
events.push(...evt);
|
|
@@ -717,12 +815,19 @@ const processBarPatch = (
|
|
|
717
815
|
events.push(evt);
|
|
718
816
|
}
|
|
719
817
|
|
|
720
|
-
//
|
|
721
|
-
|
|
818
|
+
// Resolve a broken-rhythm marker carried by the PREVIOUS note: it modifies
|
|
819
|
+
// the pair (previous note, this note). Apply now that this (the right) note exists.
|
|
820
|
+
if (pendingBroken !== null) {
|
|
722
821
|
const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest") as (NoteEvent | RestEvent)[];
|
|
723
822
|
if (noteRestEvents.length >= 2) {
|
|
724
|
-
applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2,
|
|
823
|
+
applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
|
|
725
824
|
}
|
|
825
|
+
pendingBroken = null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Stash this note's own broken marker (the right note is not parsed yet).
|
|
829
|
+
if (eventTerm.broken) {
|
|
830
|
+
pendingBroken = eventTerm.broken;
|
|
726
831
|
}
|
|
727
832
|
}
|
|
728
833
|
i++;
|
|
@@ -770,7 +875,8 @@ const convertEventTerm = (
|
|
|
770
875
|
eventTerm: ABC.EventTerm,
|
|
771
876
|
unitLength: { numerator: number; denominator: number },
|
|
772
877
|
pendingMarks: Mark[],
|
|
773
|
-
pendingContextChanges: ContextChange[]
|
|
878
|
+
pendingContextChanges: ContextChange[],
|
|
879
|
+
pitchCtx?: PitchContext
|
|
774
880
|
): Event | Event[] | undefined => {
|
|
775
881
|
const eventData = eventTerm.event;
|
|
776
882
|
if (!eventData) return undefined;
|
|
@@ -794,16 +900,24 @@ const convertEventTerm = (
|
|
|
794
900
|
rest.fullMeasure = true;
|
|
795
901
|
}
|
|
796
902
|
|
|
797
|
-
//
|
|
903
|
+
// A dynamic that precedes a rest (e.g. ABC "!p! z") has no note to attach to.
|
|
904
|
+
// Emit it as a standalone leading DynamicEvent before the rest; lilylet attaches
|
|
905
|
+
// it to the following sounding event. Other marks on a rest are dropped.
|
|
906
|
+
const leadingDynamics: DynamicEvent[] = pendingMarks
|
|
907
|
+
.filter(m => m.markType === "dynamic")
|
|
908
|
+
.map(m => ({ type: "dynamic", dynamicType: (m as { type: DynamicType }).type }));
|
|
798
909
|
pendingMarks.length = 0;
|
|
799
910
|
|
|
911
|
+
if (leadingDynamics.length > 0) {
|
|
912
|
+
return [...leadingDynamics, rest];
|
|
913
|
+
}
|
|
800
914
|
return rest;
|
|
801
915
|
}
|
|
802
916
|
|
|
803
917
|
// Note or chord
|
|
804
918
|
const pitches = chord.pitches.filter(p =>
|
|
805
919
|
p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y"
|
|
806
|
-
).map(convertPitch);
|
|
920
|
+
).map(p => convertPitch(p, pitchCtx));
|
|
807
921
|
|
|
808
922
|
if (pitches.length === 0) return undefined;
|
|
809
923
|
|
|
@@ -878,7 +992,14 @@ const convertGraceEvents = (
|
|
|
878
992
|
/**
|
|
879
993
|
* Decode an ABC tune into a LilyletDoc
|
|
880
994
|
*/
|
|
881
|
-
|
|
995
|
+
export interface DecodeOptions {
|
|
996
|
+
// Extract NotaGen catalog metadata from the leading
|
|
997
|
+
// %period/%composer/%instrumentation comments. These are NotaGen's own
|
|
998
|
+
// convention, not standard ABC, so this is off by default.
|
|
999
|
+
catalogComments?: boolean;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc => {
|
|
882
1003
|
const headers = tune.header;
|
|
883
1004
|
const body = tune.body;
|
|
884
1005
|
|
|
@@ -903,10 +1024,12 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
903
1024
|
for (const h of headers) {
|
|
904
1025
|
const comment = (h as any).comment;
|
|
905
1026
|
if (typeof comment === "string") {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1027
|
+
if (options.catalogComments) {
|
|
1028
|
+
const value = comment.trim();
|
|
1029
|
+
if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value)) metadata.genre = value;
|
|
1030
|
+
else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value)) metadata.instrument = value;
|
|
1031
|
+
else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value)) metadata.composer = value;
|
|
1032
|
+
}
|
|
910
1033
|
continue;
|
|
911
1034
|
}
|
|
912
1035
|
if ((h as any).staffLayout) continue;
|
|
@@ -996,6 +1119,12 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
996
1119
|
const measures = body.measures;
|
|
997
1120
|
const voiceSlurDepths = new Map<number, { count: number }>();
|
|
998
1121
|
|
|
1122
|
+
// Per-voice key alterations (persist across measures; mutated by inline [K:] changes).
|
|
1123
|
+
// Initialized from the tune's header key signature so bare ABC letters pick up the
|
|
1124
|
+
// key's sharps/flats, which lilylet pitches must carry explicitly.
|
|
1125
|
+
const initialKeyAlterations = keySig ? keySignatureAlterations(keySig) : new Map<Phonet, Accidental>();
|
|
1126
|
+
const voiceKeyAlterations = new Map<number, Map<Phonet, Accidental>>();
|
|
1127
|
+
|
|
999
1128
|
// Process each ABC measure into Lilylet Measure
|
|
1000
1129
|
const lilyletMeasures: Measure[] = [];
|
|
1001
1130
|
|
|
@@ -1019,12 +1148,22 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
1019
1148
|
const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
|
|
1020
1149
|
voiceSlurDepths.set(voiceNum, slurDepth);
|
|
1021
1150
|
|
|
1151
|
+
// Resolve this voice's persistent key alterations (seeded from the header key).
|
|
1152
|
+
if (!voiceKeyAlterations.has(voiceNum)) {
|
|
1153
|
+
voiceKeyAlterations.set(voiceNum, new Map(initialKeyAlterations));
|
|
1154
|
+
}
|
|
1155
|
+
// Fresh in-measure accidental memory for each measure (standard notation rule).
|
|
1156
|
+
const pitchCtx: PitchContext = {
|
|
1157
|
+
keyAlterations: voiceKeyAlterations.get(voiceNum)!,
|
|
1158
|
+
measureAccidentals: new Map(),
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1022
1161
|
// Merge all patches for this voice in this measure
|
|
1023
1162
|
const allEvents: Event[] = [];
|
|
1024
1163
|
let barline: string | undefined;
|
|
1025
1164
|
|
|
1026
1165
|
for (const patch of patches) {
|
|
1027
|
-
const result = processBarPatch(patch, unitLength, slurDepth);
|
|
1166
|
+
const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
|
|
1028
1167
|
allEvents.push(...result.events);
|
|
1029
1168
|
if (result.barline && result.barline !== "|") {
|
|
1030
1169
|
barline = result.barline;
|
|
@@ -1144,32 +1283,32 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
1144
1283
|
* Decode ABC notation string to LilyletDoc.
|
|
1145
1284
|
* If the ABC contains multiple tunes, only the first is decoded.
|
|
1146
1285
|
*/
|
|
1147
|
-
export const decode = (abcString: string): LilyletDoc => {
|
|
1286
|
+
export const decode = (abcString: string, options: DecodeOptions = {}): LilyletDoc => {
|
|
1148
1287
|
const tunes = parse(abcString);
|
|
1149
1288
|
if (!tunes || tunes.length === 0) {
|
|
1150
1289
|
throw new Error("No tunes found in ABC notation");
|
|
1151
1290
|
}
|
|
1152
|
-
return decodeTune(tunes[0]);
|
|
1291
|
+
return decodeTune(tunes[0], options);
|
|
1153
1292
|
};
|
|
1154
1293
|
|
|
1155
1294
|
/**
|
|
1156
1295
|
* Decode ABC notation string to multiple LilyletDocs (one per tune).
|
|
1157
1296
|
*/
|
|
1158
|
-
export const decodeAll = (abcString: string): LilyletDoc[] => {
|
|
1297
|
+
export const decodeAll = (abcString: string, options: DecodeOptions = {}): LilyletDoc[] => {
|
|
1159
1298
|
const tunes = parse(abcString);
|
|
1160
1299
|
if (!tunes || tunes.length === 0) {
|
|
1161
1300
|
throw new Error("No tunes found in ABC notation");
|
|
1162
1301
|
}
|
|
1163
|
-
return tunes.map(decodeTune);
|
|
1302
|
+
return tunes.map(t => decodeTune(t, options));
|
|
1164
1303
|
};
|
|
1165
1304
|
|
|
1166
1305
|
/**
|
|
1167
1306
|
* Decode an ABC file to LilyletDoc
|
|
1168
1307
|
*/
|
|
1169
|
-
export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
|
|
1308
|
+
export const decodeFile = async (filePath: string, options: DecodeOptions = {}): Promise<LilyletDoc> => {
|
|
1170
1309
|
const fs = await import("fs/promises");
|
|
1171
1310
|
const content = await fs.readFile(filePath, "utf-8");
|
|
1172
|
-
return decode(content);
|
|
1311
|
+
return decode(content, options);
|
|
1173
1312
|
};
|
|
1174
1313
|
|
|
1175
1314
|
export default {
|