@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.
- package/lib/abc/grammar.jison.js +200 -179
- package/lib/lilylet/abcDecoder.d.ts +12 -6
- package/lib/lilylet/abcDecoder.js +248 -116
- package/lib/lilylet/grammar.jison.js +195 -185
- package/lib/lilylet/meiEncoder.js +69 -14
- package/lib/lilylet/serializer.js +81 -37
- package/lib/lilylet/types.d.ts +5 -1
- package/package.json +4 -2
- package/source/abc/abc.jison +20 -6
- package/source/abc/grammar.jison.js +200 -179
- package/source/lilylet/abcDecoder.ts +272 -103
- package/source/lilylet/grammar.jison.js +195 -185
- package/source/lilylet/lilylet.jison +8 -0
- package/source/lilylet/meiEncoder.ts +75 -16
- package/source/lilylet/serializer.ts +85 -37
- package/source/lilylet/types.ts +6 -1
|
@@ -34,11 +34,42 @@ import {
|
|
|
34
34
|
NavigationMarkType,
|
|
35
35
|
Tempo,
|
|
36
36
|
BarlineEvent,
|
|
37
|
+
MarkupEvent,
|
|
38
|
+
DynamicEvent,
|
|
39
|
+
Placement,
|
|
37
40
|
} from "./types";
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
// ============ Constants ============
|
|
41
44
|
|
|
45
|
+
// NotaGen catalog tags appear as leading single-% comments in ABC files,
|
|
46
|
+
// in the order: period, composer, instrumentation. They are mapped to
|
|
47
|
+
// Lilylet metadata: period -> genre, instrumentation -> instrument.
|
|
48
|
+
const NOTAGEN_PERIOD_SET = new Set([
|
|
49
|
+
"Baroque", "Classical", "Romantic",
|
|
50
|
+
]);
|
|
51
|
+
const NOTAGEN_INSTRUMENTATION_SET = new Set([
|
|
52
|
+
"Art Song", "Chamber", "Choral", "Keyboard", "Orchestral", "Vocal-Orchestral",
|
|
53
|
+
]);
|
|
54
|
+
const NOTAGEN_COMPOSER_SET = new Set([
|
|
55
|
+
"Bach, Johann Sebastian", "Bartok, Bela", "Beethoven, Ludwig van", "Berlioz, Hector",
|
|
56
|
+
"Bizet, Georges", "Boulanger, Lili", "Boulton, Harold", "Brahms, Johannes",
|
|
57
|
+
"Burgmuller, Friedrich", "Butterworth, George", "Chaminade, Cecile", "Chausson, Ernest",
|
|
58
|
+
"Chopin, Frederic", "Corelli, Arcangelo", "Cornelius, Peter", "Debussy, Claude",
|
|
59
|
+
"Dvorak, Antonin", "Faisst, Clara", "Faure, Gabriel", "Franz, Robert",
|
|
60
|
+
"Gonzaga, Chiquinha", "Grandval, Clemence de", "Grieg, Edvard", "Handel, George Frideric",
|
|
61
|
+
"Haydn, Joseph", "Hensel, Fanny", "Holmes, Augusta Mary Anne", "Jaell, Marie",
|
|
62
|
+
"Kinkel, Johanna", "Kralik, Mathilde", "Lang, Josephine", "Lehmann, Liza",
|
|
63
|
+
"Liszt, Franz", "Mayer, Emilie", "Medtner, Nikolay", "Mendelssohn, Felix",
|
|
64
|
+
"Mozart, Wolfgang Amadeus", "Munktell, Helena", "Paradis, Maria Theresia von",
|
|
65
|
+
"Parratt, Walter", "Prokofiev, Sergey", "Rachmaninoff, Sergei", "Ravel, Maurice",
|
|
66
|
+
"Reichardt, Louise", "Saint-Georges, Joseph Bologne", "Saint-Saens, Camille",
|
|
67
|
+
"Satie, Erik", "Scarlatti, Domenico", "Schroter, Corona", "Schubert, Franz",
|
|
68
|
+
"Schumann, Clara", "Schumann, Robert", "Scriabin, Aleksandr", "Shostakovich, Dmitry",
|
|
69
|
+
"Sibelius, Jean", "Smetana, Bedrich", "Tchaikovsky, Pyotr", "Viardot, Pauline",
|
|
70
|
+
"Vivaldi, Antonio", "Warlock, Peter", "Wolf, Hugo", "Zumsteeg, Emilie",
|
|
71
|
+
]);
|
|
72
|
+
|
|
42
73
|
const ABC_PHONET_MAP: Record<string, Phonet> = {
|
|
43
74
|
"C": Phonet.c, "D": Phonet.d, "E": Phonet.e, "F": Phonet.f, "G": Phonet.g, "A": Phonet.a, "B": Phonet.b,
|
|
44
75
|
"c": Phonet.c, "d": Phonet.d, "e": Phonet.e, "f": Phonet.f, "g": Phonet.g, "a": Phonet.a, "b": Phonet.b,
|
|
@@ -97,12 +128,25 @@ const convertAccidental = (acc: number | null): Accidental | undefined => {
|
|
|
97
128
|
}
|
|
98
129
|
};
|
|
99
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
|
+
|
|
100
144
|
/**
|
|
101
145
|
* Convert ABC pitch to Lilylet Pitch
|
|
102
146
|
* Uppercase C-B = octave 0, lowercase c-b = octave 1
|
|
103
147
|
* quotes (from ' and ,) add/subtract octaves
|
|
104
148
|
*/
|
|
105
|
-
const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
|
|
149
|
+
const convertPitch = (abcPitch: ABC.Pitch, ctx?: PitchContext): Pitch => {
|
|
106
150
|
const phonet = ABC_PHONET_MAP[abcPitch.phonet];
|
|
107
151
|
if (!phonet) {
|
|
108
152
|
throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
|
|
@@ -114,10 +158,36 @@ const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
|
|
|
114
158
|
const octave = baseOctave + (abcPitch.quotes || 0);
|
|
115
159
|
|
|
116
160
|
const pitch: Pitch = { phonet, octave };
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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;
|
|
120
189
|
}
|
|
190
|
+
|
|
121
191
|
return pitch;
|
|
122
192
|
};
|
|
123
193
|
|
|
@@ -282,16 +352,60 @@ const convertKeySignature = (abcKey: ABC.KeySignature): KeySignature => {
|
|
|
282
352
|
};
|
|
283
353
|
};
|
|
284
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
|
+
|
|
285
391
|
/**
|
|
286
392
|
* Convert ABC clef string to Lilylet Clef
|
|
287
393
|
*/
|
|
288
394
|
const convertClef = (clefStr: string): Clef | undefined => {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
395
|
+
// Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
|
|
396
|
+
// ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
|
|
397
|
+
// LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
|
|
398
|
+
const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
|
|
399
|
+
const base = shift ? shift[1] : clefStr;
|
|
400
|
+
let resolved: string | undefined;
|
|
401
|
+
switch (base?.toLowerCase()) {
|
|
402
|
+
case "treble": resolved = "treble"; break;
|
|
403
|
+
case "bass": resolved = "bass"; break;
|
|
404
|
+
case "alto": case "tenor": resolved = "alto"; break;
|
|
293
405
|
default: return undefined;
|
|
294
406
|
}
|
|
407
|
+
if (shift) resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
|
|
408
|
+
return resolved as Clef;
|
|
295
409
|
};
|
|
296
410
|
|
|
297
411
|
/**
|
|
@@ -323,9 +437,60 @@ interface StaffAssignment {
|
|
|
323
437
|
|
|
324
438
|
/**
|
|
325
439
|
* Parse %%score layout to determine voice→(part, staff) mapping.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
440
|
+
*
|
|
441
|
+
* Grouping semantics (recursive, any nesting depth):
|
|
442
|
+
* [ ... ] square: each child becomes a separate part -> serialized with \\\
|
|
443
|
+
* ( ... ) arc: all voices share one staff in one part -> voices joined with \\
|
|
444
|
+
* { ... } curly: one part, each child is a separate staff -> grand staff
|
|
445
|
+
* leaf a bare voice number "1" (often wrapped as {items:["1"]}) -> one part, one staff
|
|
446
|
+
*
|
|
447
|
+
* The AST wraps each leaf voice one level deep, e.g. ( 1 2 ) becomes
|
|
448
|
+
* {bound:'arc', items:[{items:['1']},{items:['2']}]}
|
|
449
|
+
* so leaves must be collected recursively rather than assuming a fixed depth.
|
|
328
450
|
*/
|
|
451
|
+
|
|
452
|
+
type ScoreNode = ABC.StaffGroup | string;
|
|
453
|
+
|
|
454
|
+
// All voice numbers under a node, flattened.
|
|
455
|
+
const collectScoreVoices = (node: ScoreNode): number[] => {
|
|
456
|
+
if (typeof node === "string") {
|
|
457
|
+
const v = parseInt(node, 10);
|
|
458
|
+
return isNaN(v) ? [] : [v];
|
|
459
|
+
}
|
|
460
|
+
const voices: number[] = [];
|
|
461
|
+
for (const item of node.items || []) {
|
|
462
|
+
voices.push(...collectScoreVoices(item));
|
|
463
|
+
}
|
|
464
|
+
return voices;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// Expand a node into a list of parts; each part is a list of staves; each staff is a list of voices.
|
|
468
|
+
const scoreNodeToParts = (node: ScoreNode): number[][][] => {
|
|
469
|
+
if (typeof node === "string") {
|
|
470
|
+
const v = parseInt(node, 10);
|
|
471
|
+
return isNaN(v) ? [] : [[[v]]];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (node.bound === "square") {
|
|
475
|
+
// each child is its own part
|
|
476
|
+
const parts: number[][][] = [];
|
|
477
|
+
for (const child of node.items || []) {
|
|
478
|
+
parts.push(...scoreNodeToParts(child));
|
|
479
|
+
}
|
|
480
|
+
return parts;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (node.bound === "curly") {
|
|
484
|
+
// one part, each child is a separate staff (grand staff)
|
|
485
|
+
const staves = (node.items || []).map(child => collectScoreVoices(child)).filter(s => s.length > 0);
|
|
486
|
+
return staves.length > 0 ? [staves] : [];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// arc or no bound: one part, all voices on a single staff
|
|
490
|
+
const voices = collectScoreVoices(node);
|
|
491
|
+
return voices.length > 0 ? [[voices]] : [];
|
|
492
|
+
};
|
|
493
|
+
|
|
329
494
|
const parseScoreLayout = (
|
|
330
495
|
headers: any[]
|
|
331
496
|
): Map<number, StaffAssignment> | null => {
|
|
@@ -336,78 +501,14 @@ const parseScoreLayout = (
|
|
|
336
501
|
const voiceMap = new Map<number, StaffAssignment>();
|
|
337
502
|
|
|
338
503
|
let partIndex = 0;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
for (const item of group.items) {
|
|
345
|
-
if (typeof item === "string") {
|
|
346
|
-
voiceMap.set(parseInt(item), { partIndex, staffInPart });
|
|
347
|
-
} else if ((item as ABC.StaffGroup).items) {
|
|
348
|
-
const sg = item as ABC.StaffGroup;
|
|
349
|
-
for (const subItem of sg.items) {
|
|
350
|
-
if (typeof subItem === "string") {
|
|
351
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
|
|
352
|
-
} else if ((subItem as ABC.StaffGroup).items) {
|
|
353
|
-
for (const leaf of (subItem as ABC.StaffGroup).items) {
|
|
354
|
-
if (typeof leaf === "string") {
|
|
355
|
-
voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
staffInPart++;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
partIndex++;
|
|
364
|
-
} else if (group.bound === "arc" || !group.bound) {
|
|
365
|
-
// Arc or plain = voices sharing a staff in same part
|
|
366
|
-
for (const item of group.items) {
|
|
367
|
-
if (typeof item === "string") {
|
|
368
|
-
voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
|
|
369
|
-
} else if ((item as ABC.StaffGroup).items) {
|
|
370
|
-
for (const subItem of (item as ABC.StaffGroup).items) {
|
|
371
|
-
if (typeof subItem === "string") {
|
|
372
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
|
|
373
|
-
}
|
|
374
|
-
}
|
|
504
|
+
for (const top of layout) {
|
|
505
|
+
for (const part of scoreNodeToParts(top)) {
|
|
506
|
+
part.forEach((staff, staffIdx) => {
|
|
507
|
+
for (const voice of staff) {
|
|
508
|
+
voiceMap.set(voice, { partIndex, staffInPart: staffIdx + 1 });
|
|
375
509
|
}
|
|
376
|
-
}
|
|
510
|
+
});
|
|
377
511
|
partIndex++;
|
|
378
|
-
} else {
|
|
379
|
-
// Square bracket or unknown - treat each item as separate part
|
|
380
|
-
for (const item of group.items) {
|
|
381
|
-
if (typeof item === "string") {
|
|
382
|
-
voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
|
|
383
|
-
partIndex++;
|
|
384
|
-
} else if ((item as ABC.StaffGroup).items) {
|
|
385
|
-
const sg = item as ABC.StaffGroup;
|
|
386
|
-
if (sg.bound === "curly") {
|
|
387
|
-
let staffInPart = 1;
|
|
388
|
-
for (const subItem of sg.items) {
|
|
389
|
-
if (typeof subItem === "string") {
|
|
390
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
|
|
391
|
-
} else if ((subItem as ABC.StaffGroup).items) {
|
|
392
|
-
for (const leaf of (subItem as ABC.StaffGroup).items) {
|
|
393
|
-
if (typeof leaf === "string") {
|
|
394
|
-
voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
staffInPart++;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
partIndex++;
|
|
401
|
-
} else {
|
|
402
|
-
for (const subItem of sg.items) {
|
|
403
|
-
if (typeof subItem === "string") {
|
|
404
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
partIndex++;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
512
|
}
|
|
412
513
|
}
|
|
413
514
|
|
|
@@ -544,7 +645,8 @@ interface VoiceConfig {
|
|
|
544
645
|
const processBarPatch = (
|
|
545
646
|
patch: ABC.BarPatch,
|
|
546
647
|
unitLength: { numerator: number; denominator: number },
|
|
547
|
-
slurDepth: { count: number }
|
|
648
|
+
slurDepth: { count: number },
|
|
649
|
+
pitchCtx?: PitchContext
|
|
548
650
|
): { events: Event[]; barline?: string } => {
|
|
549
651
|
const events: Event[] = [];
|
|
550
652
|
const terms = patch.terms || [];
|
|
@@ -554,6 +656,11 @@ const processBarPatch = (
|
|
|
554
656
|
// Collect all events first, then handle broken rhythms and tuplets
|
|
555
657
|
const rawNoteRests: { event: NoteEvent | RestEvent; index: number; broken?: number }[] = [];
|
|
556
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
|
+
|
|
557
664
|
let i = 0;
|
|
558
665
|
while (i < terms.length) {
|
|
559
666
|
const term = terms[i];
|
|
@@ -568,10 +675,18 @@ const processBarPatch = (
|
|
|
568
675
|
events.push({ type: "context", clef } as ContextChange);
|
|
569
676
|
}
|
|
570
677
|
} else if (ctrl.value?.root) {
|
|
678
|
+
const newKey = convertKeySignature(ctrl.value);
|
|
571
679
|
events.push({
|
|
572
680
|
type: "context",
|
|
573
|
-
key:
|
|
681
|
+
key: newKey,
|
|
574
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
|
+
}
|
|
575
690
|
}
|
|
576
691
|
} else if (ctrl.name === "M") {
|
|
577
692
|
if (ctrl.value?.numerator && ctrl.value?.denominator) {
|
|
@@ -609,7 +724,7 @@ const processBarPatch = (
|
|
|
609
724
|
while (j < terms.length && collected < r) {
|
|
610
725
|
const nextTerm = terms[j];
|
|
611
726
|
if ((nextTerm as ABC.EventTerm).event) {
|
|
612
|
-
const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges);
|
|
727
|
+
const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
|
|
613
728
|
if (evt) {
|
|
614
729
|
// Push any pending context changes before tuplet
|
|
615
730
|
for (const ctx of pendingContextChanges.splice(0)) {
|
|
@@ -669,10 +784,16 @@ const processBarPatch = (
|
|
|
669
784
|
// Text
|
|
670
785
|
if ((term as ABC.TextTerm).text !== undefined) {
|
|
671
786
|
const text = (term as ABC.TextTerm).text;
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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);
|
|
676
797
|
i++;
|
|
677
798
|
continue;
|
|
678
799
|
}
|
|
@@ -686,7 +807,7 @@ const processBarPatch = (
|
|
|
686
807
|
events.push(ctx);
|
|
687
808
|
}
|
|
688
809
|
|
|
689
|
-
const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
|
|
810
|
+
const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
|
|
690
811
|
if (evt) {
|
|
691
812
|
if (Array.isArray(evt)) {
|
|
692
813
|
events.push(...evt);
|
|
@@ -694,12 +815,19 @@ const processBarPatch = (
|
|
|
694
815
|
events.push(evt);
|
|
695
816
|
}
|
|
696
817
|
|
|
697
|
-
//
|
|
698
|
-
|
|
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) {
|
|
699
821
|
const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest") as (NoteEvent | RestEvent)[];
|
|
700
822
|
if (noteRestEvents.length >= 2) {
|
|
701
|
-
applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2,
|
|
823
|
+
applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
|
|
702
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;
|
|
703
831
|
}
|
|
704
832
|
}
|
|
705
833
|
i++;
|
|
@@ -747,7 +875,8 @@ const convertEventTerm = (
|
|
|
747
875
|
eventTerm: ABC.EventTerm,
|
|
748
876
|
unitLength: { numerator: number; denominator: number },
|
|
749
877
|
pendingMarks: Mark[],
|
|
750
|
-
pendingContextChanges: ContextChange[]
|
|
878
|
+
pendingContextChanges: ContextChange[],
|
|
879
|
+
pitchCtx?: PitchContext
|
|
751
880
|
): Event | Event[] | undefined => {
|
|
752
881
|
const eventData = eventTerm.event;
|
|
753
882
|
if (!eventData) return undefined;
|
|
@@ -771,16 +900,24 @@ const convertEventTerm = (
|
|
|
771
900
|
rest.fullMeasure = true;
|
|
772
901
|
}
|
|
773
902
|
|
|
774
|
-
//
|
|
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 }));
|
|
775
909
|
pendingMarks.length = 0;
|
|
776
910
|
|
|
911
|
+
if (leadingDynamics.length > 0) {
|
|
912
|
+
return [...leadingDynamics, rest];
|
|
913
|
+
}
|
|
777
914
|
return rest;
|
|
778
915
|
}
|
|
779
916
|
|
|
780
917
|
// Note or chord
|
|
781
918
|
const pitches = chord.pitches.filter(p =>
|
|
782
919
|
p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y"
|
|
783
|
-
).map(convertPitch);
|
|
920
|
+
).map(p => convertPitch(p, pitchCtx));
|
|
784
921
|
|
|
785
922
|
if (pitches.length === 0) return undefined;
|
|
786
923
|
|
|
@@ -855,7 +992,14 @@ const convertGraceEvents = (
|
|
|
855
992
|
/**
|
|
856
993
|
* Decode an ABC tune into a LilyletDoc
|
|
857
994
|
*/
|
|
858
|
-
|
|
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 => {
|
|
859
1003
|
const headers = tune.header;
|
|
860
1004
|
const body = tune.body;
|
|
861
1005
|
|
|
@@ -878,7 +1022,16 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
878
1022
|
}
|
|
879
1023
|
|
|
880
1024
|
for (const h of headers) {
|
|
881
|
-
|
|
1025
|
+
const comment = (h as any).comment;
|
|
1026
|
+
if (typeof comment === "string") {
|
|
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
|
+
}
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
882
1035
|
if ((h as any).staffLayout) continue;
|
|
883
1036
|
|
|
884
1037
|
const header = h as { name: string; value: any };
|
|
@@ -966,6 +1119,12 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
966
1119
|
const measures = body.measures;
|
|
967
1120
|
const voiceSlurDepths = new Map<number, { count: number }>();
|
|
968
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
|
+
|
|
969
1128
|
// Process each ABC measure into Lilylet Measure
|
|
970
1129
|
const lilyletMeasures: Measure[] = [];
|
|
971
1130
|
|
|
@@ -989,12 +1148,22 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
989
1148
|
const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
|
|
990
1149
|
voiceSlurDepths.set(voiceNum, slurDepth);
|
|
991
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
|
+
|
|
992
1161
|
// Merge all patches for this voice in this measure
|
|
993
1162
|
const allEvents: Event[] = [];
|
|
994
1163
|
let barline: string | undefined;
|
|
995
1164
|
|
|
996
1165
|
for (const patch of patches) {
|
|
997
|
-
const result = processBarPatch(patch, unitLength, slurDepth);
|
|
1166
|
+
const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
|
|
998
1167
|
allEvents.push(...result.events);
|
|
999
1168
|
if (result.barline && result.barline !== "|") {
|
|
1000
1169
|
barline = result.barline;
|
|
@@ -1114,32 +1283,32 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
1114
1283
|
* Decode ABC notation string to LilyletDoc.
|
|
1115
1284
|
* If the ABC contains multiple tunes, only the first is decoded.
|
|
1116
1285
|
*/
|
|
1117
|
-
export const decode = (abcString: string): LilyletDoc => {
|
|
1286
|
+
export const decode = (abcString: string, options: DecodeOptions = {}): LilyletDoc => {
|
|
1118
1287
|
const tunes = parse(abcString);
|
|
1119
1288
|
if (!tunes || tunes.length === 0) {
|
|
1120
1289
|
throw new Error("No tunes found in ABC notation");
|
|
1121
1290
|
}
|
|
1122
|
-
return decodeTune(tunes[0]);
|
|
1291
|
+
return decodeTune(tunes[0], options);
|
|
1123
1292
|
};
|
|
1124
1293
|
|
|
1125
1294
|
/**
|
|
1126
1295
|
* Decode ABC notation string to multiple LilyletDocs (one per tune).
|
|
1127
1296
|
*/
|
|
1128
|
-
export const decodeAll = (abcString: string): LilyletDoc[] => {
|
|
1297
|
+
export const decodeAll = (abcString: string, options: DecodeOptions = {}): LilyletDoc[] => {
|
|
1129
1298
|
const tunes = parse(abcString);
|
|
1130
1299
|
if (!tunes || tunes.length === 0) {
|
|
1131
1300
|
throw new Error("No tunes found in ABC notation");
|
|
1132
1301
|
}
|
|
1133
|
-
return tunes.map(decodeTune);
|
|
1302
|
+
return tunes.map(t => decodeTune(t, options));
|
|
1134
1303
|
};
|
|
1135
1304
|
|
|
1136
1305
|
/**
|
|
1137
1306
|
* Decode an ABC file to LilyletDoc
|
|
1138
1307
|
*/
|
|
1139
|
-
export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
|
|
1308
|
+
export const decodeFile = async (filePath: string, options: DecodeOptions = {}): Promise<LilyletDoc> => {
|
|
1140
1309
|
const fs = await import("fs/promises");
|
|
1141
1310
|
const content = await fs.readFile(filePath, "utf-8");
|
|
1142
|
-
return decode(content);
|
|
1311
|
+
return decode(content, options);
|
|
1143
1312
|
};
|
|
1144
1313
|
|
|
1145
1314
|
export default {
|