@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
|
@@ -4,22 +4,28 @@
|
|
|
4
4
|
* Converts ABC notation files to Lilylet's internal LilyletDoc format.
|
|
5
5
|
*/
|
|
6
6
|
import { LilyletDoc } from "./types";
|
|
7
|
+
/**
|
|
8
|
+
* Decode an ABC tune into a LilyletDoc
|
|
9
|
+
*/
|
|
10
|
+
export interface DecodeOptions {
|
|
11
|
+
catalogComments?: boolean;
|
|
12
|
+
}
|
|
7
13
|
/**
|
|
8
14
|
* Decode ABC notation string to LilyletDoc.
|
|
9
15
|
* If the ABC contains multiple tunes, only the first is decoded.
|
|
10
16
|
*/
|
|
11
|
-
export declare const decode: (abcString: string) => LilyletDoc;
|
|
17
|
+
export declare const decode: (abcString: string, options?: DecodeOptions) => LilyletDoc;
|
|
12
18
|
/**
|
|
13
19
|
* Decode ABC notation string to multiple LilyletDocs (one per tune).
|
|
14
20
|
*/
|
|
15
|
-
export declare const decodeAll: (abcString: string) => LilyletDoc[];
|
|
21
|
+
export declare const decodeAll: (abcString: string, options?: DecodeOptions) => LilyletDoc[];
|
|
16
22
|
/**
|
|
17
23
|
* Decode an ABC file to LilyletDoc
|
|
18
24
|
*/
|
|
19
|
-
export declare const decodeFile: (filePath: string) => Promise<LilyletDoc>;
|
|
25
|
+
export declare const decodeFile: (filePath: string, options?: DecodeOptions) => Promise<LilyletDoc>;
|
|
20
26
|
declare const _default: {
|
|
21
|
-
decode: (abcString: string) => LilyletDoc;
|
|
22
|
-
decodeAll: (abcString: string) => LilyletDoc[];
|
|
23
|
-
decodeFile: (filePath: string) => Promise<LilyletDoc>;
|
|
27
|
+
decode: (abcString: string, options?: DecodeOptions) => LilyletDoc;
|
|
28
|
+
decodeAll: (abcString: string, options?: DecodeOptions) => LilyletDoc[];
|
|
29
|
+
decodeFile: (filePath: string, options?: DecodeOptions) => Promise<LilyletDoc>;
|
|
24
30
|
};
|
|
25
31
|
export default _default;
|
|
@@ -4,8 +4,35 @@
|
|
|
4
4
|
* Converts ABC notation files to Lilylet's internal LilyletDoc format.
|
|
5
5
|
*/
|
|
6
6
|
import parse from "../abc/parser.js";
|
|
7
|
-
import { Phonet, Accidental,
|
|
7
|
+
import { Phonet, Accidental, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
|
|
8
8
|
// ============ Constants ============
|
|
9
|
+
// NotaGen catalog tags appear as leading single-% comments in ABC files,
|
|
10
|
+
// in the order: period, composer, instrumentation. They are mapped to
|
|
11
|
+
// Lilylet metadata: period -> genre, instrumentation -> instrument.
|
|
12
|
+
const NOTAGEN_PERIOD_SET = new Set([
|
|
13
|
+
"Baroque", "Classical", "Romantic",
|
|
14
|
+
]);
|
|
15
|
+
const NOTAGEN_INSTRUMENTATION_SET = new Set([
|
|
16
|
+
"Art Song", "Chamber", "Choral", "Keyboard", "Orchestral", "Vocal-Orchestral",
|
|
17
|
+
]);
|
|
18
|
+
const NOTAGEN_COMPOSER_SET = new Set([
|
|
19
|
+
"Bach, Johann Sebastian", "Bartok, Bela", "Beethoven, Ludwig van", "Berlioz, Hector",
|
|
20
|
+
"Bizet, Georges", "Boulanger, Lili", "Boulton, Harold", "Brahms, Johannes",
|
|
21
|
+
"Burgmuller, Friedrich", "Butterworth, George", "Chaminade, Cecile", "Chausson, Ernest",
|
|
22
|
+
"Chopin, Frederic", "Corelli, Arcangelo", "Cornelius, Peter", "Debussy, Claude",
|
|
23
|
+
"Dvorak, Antonin", "Faisst, Clara", "Faure, Gabriel", "Franz, Robert",
|
|
24
|
+
"Gonzaga, Chiquinha", "Grandval, Clemence de", "Grieg, Edvard", "Handel, George Frideric",
|
|
25
|
+
"Haydn, Joseph", "Hensel, Fanny", "Holmes, Augusta Mary Anne", "Jaell, Marie",
|
|
26
|
+
"Kinkel, Johanna", "Kralik, Mathilde", "Lang, Josephine", "Lehmann, Liza",
|
|
27
|
+
"Liszt, Franz", "Mayer, Emilie", "Medtner, Nikolay", "Mendelssohn, Felix",
|
|
28
|
+
"Mozart, Wolfgang Amadeus", "Munktell, Helena", "Paradis, Maria Theresia von",
|
|
29
|
+
"Parratt, Walter", "Prokofiev, Sergey", "Rachmaninoff, Sergei", "Ravel, Maurice",
|
|
30
|
+
"Reichardt, Louise", "Saint-Georges, Joseph Bologne", "Saint-Saens, Camille",
|
|
31
|
+
"Satie, Erik", "Scarlatti, Domenico", "Schroter, Corona", "Schubert, Franz",
|
|
32
|
+
"Schumann, Clara", "Schumann, Robert", "Scriabin, Aleksandr", "Shostakovich, Dmitry",
|
|
33
|
+
"Sibelius, Jean", "Smetana, Bedrich", "Tchaikovsky, Pyotr", "Viardot, Pauline",
|
|
34
|
+
"Vivaldi, Antonio", "Warlock, Peter", "Wolf, Hugo", "Zumsteeg, Emilie",
|
|
35
|
+
]);
|
|
9
36
|
const ABC_PHONET_MAP = {
|
|
10
37
|
"C": Phonet.c, "D": Phonet.d, "E": Phonet.e, "F": Phonet.f, "G": Phonet.g, "A": Phonet.a, "B": Phonet.b,
|
|
11
38
|
"c": Phonet.c, "d": Phonet.d, "e": Phonet.e, "f": Phonet.f, "g": Phonet.g, "a": Phonet.a, "b": Phonet.b,
|
|
@@ -64,7 +91,7 @@ const convertAccidental = (acc) => {
|
|
|
64
91
|
* Uppercase C-B = octave 0, lowercase c-b = octave 1
|
|
65
92
|
* quotes (from ' and ,) add/subtract octaves
|
|
66
93
|
*/
|
|
67
|
-
const convertPitch = (abcPitch) => {
|
|
94
|
+
const convertPitch = (abcPitch, ctx) => {
|
|
68
95
|
const phonet = ABC_PHONET_MAP[abcPitch.phonet];
|
|
69
96
|
if (!phonet) {
|
|
70
97
|
throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
|
|
@@ -74,9 +101,38 @@ const convertPitch = (abcPitch) => {
|
|
|
74
101
|
const baseOctave = isLower ? 1 : 0;
|
|
75
102
|
const octave = baseOctave + (abcPitch.quotes || 0);
|
|
76
103
|
const pitch = { phonet, octave };
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
// Resolve the effective accidental.
|
|
105
|
+
const explicit = convertAccidental(abcPitch.acc); // accidental written on this note
|
|
106
|
+
const hasExplicit = abcPitch.acc !== null && abcPitch.acc !== undefined;
|
|
107
|
+
if (ctx) {
|
|
108
|
+
const memKey = `${phonet}:${octave}`;
|
|
109
|
+
if (hasExplicit) {
|
|
110
|
+
// Explicit accidental (incl. natural) overrides and is remembered for the measure.
|
|
111
|
+
if (abcPitch.acc === 0) {
|
|
112
|
+
ctx.measureAccidentals.set(memKey, "natural");
|
|
113
|
+
// natural cancels the key alteration → no accidental on the lilylet pitch
|
|
114
|
+
}
|
|
115
|
+
else if (explicit) {
|
|
116
|
+
ctx.measureAccidentals.set(memKey, explicit);
|
|
117
|
+
pitch.accidental = explicit;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// No explicit accidental: inherit from in-measure memory, else the key signature.
|
|
122
|
+
const remembered = ctx.measureAccidentals.get(memKey);
|
|
123
|
+
if (remembered !== undefined) {
|
|
124
|
+
if (remembered !== "natural")
|
|
125
|
+
pitch.accidental = remembered;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const fromKey = ctx.keyAlterations.get(phonet);
|
|
129
|
+
if (fromKey)
|
|
130
|
+
pitch.accidental = fromKey;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (explicit) {
|
|
135
|
+
pitch.accidental = explicit;
|
|
80
136
|
}
|
|
81
137
|
return pitch;
|
|
82
138
|
};
|
|
@@ -233,17 +289,71 @@ const convertKeySignature = (abcKey) => {
|
|
|
233
289
|
mode: (abcKey.mode === "minor" || abcKey.mode === "min") ? "minor" : "major",
|
|
234
290
|
};
|
|
235
291
|
};
|
|
292
|
+
// Circle-of-fifths: a lilylet key (pitch+accidental+mode) → set of letters the key
|
|
293
|
+
// signature alters, and the direction (sharp/flat). ABC note letters inherit these
|
|
294
|
+
// alterations implicitly (e.g. K:Eb makes B sound as Bb); lilylet pitches are absolute,
|
|
295
|
+
// so the decoder must bake the alteration into each pitch's accidental.
|
|
296
|
+
const SHARP_ORDER = [Phonet.f, Phonet.c, Phonet.g, Phonet.d, Phonet.a, Phonet.e, Phonet.b];
|
|
297
|
+
const FLAT_ORDER = [Phonet.b, Phonet.e, Phonet.a, Phonet.d, Phonet.g, Phonet.c, Phonet.f];
|
|
298
|
+
const KEY_NAME_TO_FIFTHS = {
|
|
299
|
+
c: 0, g: 1, d: 2, a: 3, e: 4, b: 5, "f#": 6, "c#": 7,
|
|
300
|
+
f: -1, bb: -2, eb: -3, ab: -4, db: -5, gb: -6, cb: -7,
|
|
301
|
+
};
|
|
302
|
+
// Compute the number of sharps/flats (fifths) for a resolved KeySignature.
|
|
303
|
+
const keySignatureFifths = (key) => {
|
|
304
|
+
let letter = key.pitch;
|
|
305
|
+
if (key.accidental === Accidental.sharp)
|
|
306
|
+
letter += "#";
|
|
307
|
+
else if (key.accidental === Accidental.flat)
|
|
308
|
+
letter += "b";
|
|
309
|
+
let fifths = KEY_NAME_TO_FIFTHS[letter];
|
|
310
|
+
if (fifths === undefined)
|
|
311
|
+
fifths = 0;
|
|
312
|
+
// Minor keys share the fifths of their relative major (3 fifths up).
|
|
313
|
+
if (key.mode === "minor")
|
|
314
|
+
fifths += 3;
|
|
315
|
+
return fifths;
|
|
316
|
+
};
|
|
317
|
+
// Map of phonet → accidental implied by the key signature (only altered letters present).
|
|
318
|
+
const keySignatureAlterations = (key) => {
|
|
319
|
+
const fifths = keySignatureFifths(key);
|
|
320
|
+
const map = new Map();
|
|
321
|
+
if (fifths > 0) {
|
|
322
|
+
for (let i = 0; i < fifths && i < SHARP_ORDER.length; i++)
|
|
323
|
+
map.set(SHARP_ORDER[i], Accidental.sharp);
|
|
324
|
+
}
|
|
325
|
+
else if (fifths < 0) {
|
|
326
|
+
for (let i = 0; i < -fifths && i < FLAT_ORDER.length; i++)
|
|
327
|
+
map.set(FLAT_ORDER[i], Accidental.flat);
|
|
328
|
+
}
|
|
329
|
+
return map;
|
|
330
|
+
};
|
|
236
331
|
/**
|
|
237
332
|
* Convert ABC clef string to Lilylet Clef
|
|
238
333
|
*/
|
|
239
334
|
const convertClef = (clefStr) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
335
|
+
// Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
|
|
336
|
+
// ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
|
|
337
|
+
// LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
|
|
338
|
+
const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
|
|
339
|
+
const base = shift ? shift[1] : clefStr;
|
|
340
|
+
let resolved;
|
|
341
|
+
switch (base?.toLowerCase()) {
|
|
342
|
+
case "treble":
|
|
343
|
+
resolved = "treble";
|
|
344
|
+
break;
|
|
345
|
+
case "bass":
|
|
346
|
+
resolved = "bass";
|
|
347
|
+
break;
|
|
243
348
|
case "alto":
|
|
244
|
-
case "tenor":
|
|
349
|
+
case "tenor":
|
|
350
|
+
resolved = "alto";
|
|
351
|
+
break;
|
|
245
352
|
default: return undefined;
|
|
246
353
|
}
|
|
354
|
+
if (shift)
|
|
355
|
+
resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
|
|
356
|
+
return resolved;
|
|
247
357
|
};
|
|
248
358
|
/**
|
|
249
359
|
* Convert ABC barline to Lilylet barline style
|
|
@@ -267,11 +377,41 @@ const convertBarline = (bar) => {
|
|
|
267
377
|
return "|";
|
|
268
378
|
}
|
|
269
379
|
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
380
|
+
// All voice numbers under a node, flattened.
|
|
381
|
+
const collectScoreVoices = (node) => {
|
|
382
|
+
if (typeof node === "string") {
|
|
383
|
+
const v = parseInt(node, 10);
|
|
384
|
+
return isNaN(v) ? [] : [v];
|
|
385
|
+
}
|
|
386
|
+
const voices = [];
|
|
387
|
+
for (const item of node.items || []) {
|
|
388
|
+
voices.push(...collectScoreVoices(item));
|
|
389
|
+
}
|
|
390
|
+
return voices;
|
|
391
|
+
};
|
|
392
|
+
// Expand a node into a list of parts; each part is a list of staves; each staff is a list of voices.
|
|
393
|
+
const scoreNodeToParts = (node) => {
|
|
394
|
+
if (typeof node === "string") {
|
|
395
|
+
const v = parseInt(node, 10);
|
|
396
|
+
return isNaN(v) ? [] : [[[v]]];
|
|
397
|
+
}
|
|
398
|
+
if (node.bound === "square") {
|
|
399
|
+
// each child is its own part
|
|
400
|
+
const parts = [];
|
|
401
|
+
for (const child of node.items || []) {
|
|
402
|
+
parts.push(...scoreNodeToParts(child));
|
|
403
|
+
}
|
|
404
|
+
return parts;
|
|
405
|
+
}
|
|
406
|
+
if (node.bound === "curly") {
|
|
407
|
+
// one part, each child is a separate staff (grand staff)
|
|
408
|
+
const staves = (node.items || []).map(child => collectScoreVoices(child)).filter(s => s.length > 0);
|
|
409
|
+
return staves.length > 0 ? [staves] : [];
|
|
410
|
+
}
|
|
411
|
+
// arc or no bound: one part, all voices on a single staff
|
|
412
|
+
const voices = collectScoreVoices(node);
|
|
413
|
+
return voices.length > 0 ? [[voices]] : [];
|
|
414
|
+
};
|
|
275
415
|
const parseScoreLayout = (headers) => {
|
|
276
416
|
const layoutHeader = headers.find((h) => h.staffLayout);
|
|
277
417
|
if (!layoutHeader)
|
|
@@ -279,86 +419,15 @@ const parseScoreLayout = (headers) => {
|
|
|
279
419
|
const layout = layoutHeader.staffLayout;
|
|
280
420
|
const voiceMap = new Map();
|
|
281
421
|
let partIndex = 0;
|
|
282
|
-
for (const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (typeof item === "string") {
|
|
288
|
-
voiceMap.set(parseInt(item), { partIndex, staffInPart });
|
|
422
|
+
for (const top of layout) {
|
|
423
|
+
for (const part of scoreNodeToParts(top)) {
|
|
424
|
+
part.forEach((staff, staffIdx) => {
|
|
425
|
+
for (const voice of staff) {
|
|
426
|
+
voiceMap.set(voice, { partIndex, staffInPart: staffIdx + 1 });
|
|
289
427
|
}
|
|
290
|
-
|
|
291
|
-
const sg = item;
|
|
292
|
-
for (const subItem of sg.items) {
|
|
293
|
-
if (typeof subItem === "string") {
|
|
294
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
|
|
295
|
-
}
|
|
296
|
-
else if (subItem.items) {
|
|
297
|
-
for (const leaf of subItem.items) {
|
|
298
|
-
if (typeof leaf === "string") {
|
|
299
|
-
voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
staffInPart++;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
partIndex++;
|
|
308
|
-
}
|
|
309
|
-
else if (group.bound === "arc" || !group.bound) {
|
|
310
|
-
// Arc or plain = voices sharing a staff in same part
|
|
311
|
-
for (const item of group.items) {
|
|
312
|
-
if (typeof item === "string") {
|
|
313
|
-
voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
|
|
314
|
-
}
|
|
315
|
-
else if (item.items) {
|
|
316
|
-
for (const subItem of item.items) {
|
|
317
|
-
if (typeof subItem === "string") {
|
|
318
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
428
|
+
});
|
|
323
429
|
partIndex++;
|
|
324
430
|
}
|
|
325
|
-
else {
|
|
326
|
-
// Square bracket or unknown - treat each item as separate part
|
|
327
|
-
for (const item of group.items) {
|
|
328
|
-
if (typeof item === "string") {
|
|
329
|
-
voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
|
|
330
|
-
partIndex++;
|
|
331
|
-
}
|
|
332
|
-
else if (item.items) {
|
|
333
|
-
const sg = item;
|
|
334
|
-
if (sg.bound === "curly") {
|
|
335
|
-
let staffInPart = 1;
|
|
336
|
-
for (const subItem of sg.items) {
|
|
337
|
-
if (typeof subItem === "string") {
|
|
338
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
|
|
339
|
-
}
|
|
340
|
-
else if (subItem.items) {
|
|
341
|
-
for (const leaf of subItem.items) {
|
|
342
|
-
if (typeof leaf === "string") {
|
|
343
|
-
voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
staffInPart++;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
partIndex++;
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
352
|
-
for (const subItem of sg.items) {
|
|
353
|
-
if (typeof subItem === "string") {
|
|
354
|
-
voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
partIndex++;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
431
|
}
|
|
363
432
|
return voiceMap.size > 0 ? voiceMap : null;
|
|
364
433
|
};
|
|
@@ -491,13 +560,17 @@ const processExpressiveTerm = (term, pendingMarks, pendingContextChanges, slurDe
|
|
|
491
560
|
/**
|
|
492
561
|
* Process a single ABC BarPatch (one voice's content for one measure) into events.
|
|
493
562
|
*/
|
|
494
|
-
const processBarPatch = (patch, unitLength, slurDepth) => {
|
|
563
|
+
const processBarPatch = (patch, unitLength, slurDepth, pitchCtx) => {
|
|
495
564
|
const events = [];
|
|
496
565
|
const terms = patch.terms || [];
|
|
497
566
|
const pendingMarks = [];
|
|
498
567
|
const pendingContextChanges = [];
|
|
499
568
|
// Collect all events first, then handle broken rhythms and tuplets
|
|
500
569
|
const rawNoteRests = [];
|
|
570
|
+
// A broken-rhythm marker (>, <) belongs to the LEFT note but modifies the pair
|
|
571
|
+
// (left, right). We can only apply it once the right note has been pushed, so we
|
|
572
|
+
// stash it here and resolve it when the next note/rest arrives.
|
|
573
|
+
let pendingBroken = null;
|
|
501
574
|
let i = 0;
|
|
502
575
|
while (i < terms.length) {
|
|
503
576
|
const term = terms[i];
|
|
@@ -512,10 +585,19 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
|
|
|
512
585
|
}
|
|
513
586
|
}
|
|
514
587
|
else if (ctrl.value?.root) {
|
|
588
|
+
const newKey = convertKeySignature(ctrl.value);
|
|
515
589
|
events.push({
|
|
516
590
|
type: "context",
|
|
517
|
-
key:
|
|
591
|
+
key: newKey,
|
|
518
592
|
});
|
|
593
|
+
// Update the active key alterations and clear in-measure accidentals.
|
|
594
|
+
if (pitchCtx) {
|
|
595
|
+
const alt = keySignatureAlterations(newKey);
|
|
596
|
+
pitchCtx.keyAlterations.clear();
|
|
597
|
+
for (const [k, v] of alt)
|
|
598
|
+
pitchCtx.keyAlterations.set(k, v);
|
|
599
|
+
pitchCtx.measureAccidentals.clear();
|
|
600
|
+
}
|
|
519
601
|
}
|
|
520
602
|
}
|
|
521
603
|
else if (ctrl.name === "M") {
|
|
@@ -554,7 +636,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
|
|
|
554
636
|
while (j < terms.length && collected < r) {
|
|
555
637
|
const nextTerm = terms[j];
|
|
556
638
|
if (nextTerm.event) {
|
|
557
|
-
const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges);
|
|
639
|
+
const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
|
|
558
640
|
if (evt) {
|
|
559
641
|
// Push any pending context changes before tuplet
|
|
560
642
|
for (const ctx of pendingContextChanges.splice(0)) {
|
|
@@ -613,10 +695,25 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
|
|
|
613
695
|
// Text
|
|
614
696
|
if (term.text !== undefined) {
|
|
615
697
|
const text = term.text;
|
|
616
|
-
//
|
|
698
|
+
// ABC chord/annotation text: a leading ^ / _ marks placement above / below;
|
|
699
|
+
// other position prefixes (<,>,@) just denote placement we don't model, so strip.
|
|
700
|
+
let placement;
|
|
701
|
+
let content = text;
|
|
617
702
|
if (text.startsWith("^")) {
|
|
618
|
-
|
|
703
|
+
placement = Placement.above;
|
|
704
|
+
content = text.slice(1);
|
|
619
705
|
}
|
|
706
|
+
else if (text.startsWith("_")) {
|
|
707
|
+
placement = Placement.below;
|
|
708
|
+
content = text.slice(1);
|
|
709
|
+
}
|
|
710
|
+
else if (/^[<>@]/.test(text)) {
|
|
711
|
+
content = text.slice(1);
|
|
712
|
+
}
|
|
713
|
+
const markup = { type: "markup", content };
|
|
714
|
+
if (placement)
|
|
715
|
+
markup.placement = placement;
|
|
716
|
+
events.push(markup);
|
|
620
717
|
i++;
|
|
621
718
|
continue;
|
|
622
719
|
}
|
|
@@ -627,7 +724,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
|
|
|
627
724
|
for (const ctx of pendingContextChanges.splice(0)) {
|
|
628
725
|
events.push(ctx);
|
|
629
726
|
}
|
|
630
|
-
const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
|
|
727
|
+
const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
|
|
631
728
|
if (evt) {
|
|
632
729
|
if (Array.isArray(evt)) {
|
|
633
730
|
events.push(...evt);
|
|
@@ -635,12 +732,18 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
|
|
|
635
732
|
else {
|
|
636
733
|
events.push(evt);
|
|
637
734
|
}
|
|
638
|
-
//
|
|
639
|
-
|
|
735
|
+
// Resolve a broken-rhythm marker carried by the PREVIOUS note: it modifies
|
|
736
|
+
// the pair (previous note, this note). Apply now that this (the right) note exists.
|
|
737
|
+
if (pendingBroken !== null) {
|
|
640
738
|
const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest");
|
|
641
739
|
if (noteRestEvents.length >= 2) {
|
|
642
|
-
applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2,
|
|
740
|
+
applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
|
|
643
741
|
}
|
|
742
|
+
pendingBroken = null;
|
|
743
|
+
}
|
|
744
|
+
// Stash this note's own broken marker (the right note is not parsed yet).
|
|
745
|
+
if (eventTerm.broken) {
|
|
746
|
+
pendingBroken = eventTerm.broken;
|
|
644
747
|
}
|
|
645
748
|
}
|
|
646
749
|
i++;
|
|
@@ -686,7 +789,7 @@ const getDefaultTupletMultiplier = (p) => {
|
|
|
686
789
|
/**
|
|
687
790
|
* Convert a single ABC EventTerm to Lilylet event(s)
|
|
688
791
|
*/
|
|
689
|
-
const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges) => {
|
|
792
|
+
const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx) => {
|
|
690
793
|
const eventData = eventTerm.event;
|
|
691
794
|
if (!eventData)
|
|
692
795
|
return undefined;
|
|
@@ -707,12 +810,20 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
|
|
|
707
810
|
if (firstPitch.phonet === "Z") {
|
|
708
811
|
rest.fullMeasure = true;
|
|
709
812
|
}
|
|
710
|
-
//
|
|
813
|
+
// A dynamic that precedes a rest (e.g. ABC "!p! z") has no note to attach to.
|
|
814
|
+
// Emit it as a standalone leading DynamicEvent before the rest; lilylet attaches
|
|
815
|
+
// it to the following sounding event. Other marks on a rest are dropped.
|
|
816
|
+
const leadingDynamics = pendingMarks
|
|
817
|
+
.filter(m => m.markType === "dynamic")
|
|
818
|
+
.map(m => ({ type: "dynamic", dynamicType: m.type }));
|
|
711
819
|
pendingMarks.length = 0;
|
|
820
|
+
if (leadingDynamics.length > 0) {
|
|
821
|
+
return [...leadingDynamics, rest];
|
|
822
|
+
}
|
|
712
823
|
return rest;
|
|
713
824
|
}
|
|
714
825
|
// Note or chord
|
|
715
|
-
const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y").map(convertPitch);
|
|
826
|
+
const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y").map(p => convertPitch(p, pitchCtx));
|
|
716
827
|
if (pitches.length === 0)
|
|
717
828
|
return undefined;
|
|
718
829
|
const duration = convertDuration(eventData.duration, unitLength);
|
|
@@ -766,11 +877,7 @@ const convertGraceEvents = (graceTerm, unitLength) => {
|
|
|
766
877
|
}
|
|
767
878
|
return events;
|
|
768
879
|
};
|
|
769
|
-
|
|
770
|
-
/**
|
|
771
|
-
* Decode an ABC tune into a LilyletDoc
|
|
772
|
-
*/
|
|
773
|
-
const decodeTune = (tune) => {
|
|
880
|
+
const decodeTune = (tune, options = {}) => {
|
|
774
881
|
const headers = tune.header;
|
|
775
882
|
const body = tune.body;
|
|
776
883
|
// Extract header fields
|
|
@@ -790,8 +897,19 @@ const decodeTune = (tune) => {
|
|
|
790
897
|
}
|
|
791
898
|
}
|
|
792
899
|
for (const h of headers) {
|
|
793
|
-
|
|
900
|
+
const comment = h.comment;
|
|
901
|
+
if (typeof comment === "string") {
|
|
902
|
+
if (options.catalogComments) {
|
|
903
|
+
const value = comment.trim();
|
|
904
|
+
if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value))
|
|
905
|
+
metadata.genre = value;
|
|
906
|
+
else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value))
|
|
907
|
+
metadata.instrument = value;
|
|
908
|
+
else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value))
|
|
909
|
+
metadata.composer = value;
|
|
910
|
+
}
|
|
794
911
|
continue;
|
|
912
|
+
}
|
|
795
913
|
if (h.staffLayout)
|
|
796
914
|
continue;
|
|
797
915
|
const header = h;
|
|
@@ -881,6 +999,11 @@ const decodeTune = (tune) => {
|
|
|
881
999
|
// ABC measures contain BarPatches, each with a voice control V:n
|
|
882
1000
|
const measures = body.measures;
|
|
883
1001
|
const voiceSlurDepths = new Map();
|
|
1002
|
+
// Per-voice key alterations (persist across measures; mutated by inline [K:] changes).
|
|
1003
|
+
// Initialized from the tune's header key signature so bare ABC letters pick up the
|
|
1004
|
+
// key's sharps/flats, which lilylet pitches must carry explicitly.
|
|
1005
|
+
const initialKeyAlterations = keySig ? keySignatureAlterations(keySig) : new Map();
|
|
1006
|
+
const voiceKeyAlterations = new Map();
|
|
884
1007
|
// Process each ABC measure into Lilylet Measure
|
|
885
1008
|
const lilyletMeasures = [];
|
|
886
1009
|
for (let mi = 0; mi < measures.length; mi++) {
|
|
@@ -899,11 +1022,20 @@ const decodeTune = (tune) => {
|
|
|
899
1022
|
for (const [voiceNum, patches] of voicePatches) {
|
|
900
1023
|
const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
|
|
901
1024
|
voiceSlurDepths.set(voiceNum, slurDepth);
|
|
1025
|
+
// Resolve this voice's persistent key alterations (seeded from the header key).
|
|
1026
|
+
if (!voiceKeyAlterations.has(voiceNum)) {
|
|
1027
|
+
voiceKeyAlterations.set(voiceNum, new Map(initialKeyAlterations));
|
|
1028
|
+
}
|
|
1029
|
+
// Fresh in-measure accidental memory for each measure (standard notation rule).
|
|
1030
|
+
const pitchCtx = {
|
|
1031
|
+
keyAlterations: voiceKeyAlterations.get(voiceNum),
|
|
1032
|
+
measureAccidentals: new Map(),
|
|
1033
|
+
};
|
|
902
1034
|
// Merge all patches for this voice in this measure
|
|
903
1035
|
const allEvents = [];
|
|
904
1036
|
let barline;
|
|
905
1037
|
for (const patch of patches) {
|
|
906
|
-
const result = processBarPatch(patch, unitLength, slurDepth);
|
|
1038
|
+
const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
|
|
907
1039
|
allEvents.push(...result.events);
|
|
908
1040
|
if (result.barline && result.barline !== "|") {
|
|
909
1041
|
barline = result.barline;
|
|
@@ -1003,30 +1135,30 @@ const decodeTune = (tune) => {
|
|
|
1003
1135
|
* Decode ABC notation string to LilyletDoc.
|
|
1004
1136
|
* If the ABC contains multiple tunes, only the first is decoded.
|
|
1005
1137
|
*/
|
|
1006
|
-
export const decode = (abcString) => {
|
|
1138
|
+
export const decode = (abcString, options = {}) => {
|
|
1007
1139
|
const tunes = parse(abcString);
|
|
1008
1140
|
if (!tunes || tunes.length === 0) {
|
|
1009
1141
|
throw new Error("No tunes found in ABC notation");
|
|
1010
1142
|
}
|
|
1011
|
-
return decodeTune(tunes[0]);
|
|
1143
|
+
return decodeTune(tunes[0], options);
|
|
1012
1144
|
};
|
|
1013
1145
|
/**
|
|
1014
1146
|
* Decode ABC notation string to multiple LilyletDocs (one per tune).
|
|
1015
1147
|
*/
|
|
1016
|
-
export const decodeAll = (abcString) => {
|
|
1148
|
+
export const decodeAll = (abcString, options = {}) => {
|
|
1017
1149
|
const tunes = parse(abcString);
|
|
1018
1150
|
if (!tunes || tunes.length === 0) {
|
|
1019
1151
|
throw new Error("No tunes found in ABC notation");
|
|
1020
1152
|
}
|
|
1021
|
-
return tunes.map(decodeTune);
|
|
1153
|
+
return tunes.map(t => decodeTune(t, options));
|
|
1022
1154
|
};
|
|
1023
1155
|
/**
|
|
1024
1156
|
* Decode an ABC file to LilyletDoc
|
|
1025
1157
|
*/
|
|
1026
|
-
export const decodeFile = async (filePath) => {
|
|
1158
|
+
export const decodeFile = async (filePath, options = {}) => {
|
|
1027
1159
|
const fs = await import("fs/promises");
|
|
1028
1160
|
const content = await fs.readFile(filePath, "utf-8");
|
|
1029
|
-
return decode(content);
|
|
1161
|
+
return decode(content, options);
|
|
1030
1162
|
};
|
|
1031
1163
|
export default {
|
|
1032
1164
|
decode,
|