@k-l-lambda/lilylet 0.1.65 → 0.1.67
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.js +98 -88
- package/lib/lilylet/meiEncoder.js +52 -10
- package/lib/lilylet/serializer.js +32 -16
- package/package.json +4 -1
- package/source/abc/abc.jison +19 -4
- package/source/abc/grammar.jison.js +200 -179
- package/source/lilylet/abcDecoder.ts +107 -77
- package/source/lilylet/meiEncoder.ts +50 -12
- package/source/lilylet/serializer.ts +34 -17
|
@@ -39,6 +39,34 @@ import {
|
|
|
39
39
|
|
|
40
40
|
// ============ Constants ============
|
|
41
41
|
|
|
42
|
+
// NotaGen catalog tags appear as leading single-% comments in ABC files,
|
|
43
|
+
// in the order: period, composer, instrumentation. They are mapped to
|
|
44
|
+
// Lilylet metadata: period -> genre, instrumentation -> instrument.
|
|
45
|
+
const NOTAGEN_PERIOD_SET = new Set([
|
|
46
|
+
"Baroque", "Classical", "Romantic",
|
|
47
|
+
]);
|
|
48
|
+
const NOTAGEN_INSTRUMENTATION_SET = new Set([
|
|
49
|
+
"Art Song", "Chamber", "Choral", "Keyboard", "Orchestral", "Vocal-Orchestral",
|
|
50
|
+
]);
|
|
51
|
+
const NOTAGEN_COMPOSER_SET = new Set([
|
|
52
|
+
"Bach, Johann Sebastian", "Bartok, Bela", "Beethoven, Ludwig van", "Berlioz, Hector",
|
|
53
|
+
"Bizet, Georges", "Boulanger, Lili", "Boulton, Harold", "Brahms, Johannes",
|
|
54
|
+
"Burgmuller, Friedrich", "Butterworth, George", "Chaminade, Cecile", "Chausson, Ernest",
|
|
55
|
+
"Chopin, Frederic", "Corelli, Arcangelo", "Cornelius, Peter", "Debussy, Claude",
|
|
56
|
+
"Dvorak, Antonin", "Faisst, Clara", "Faure, Gabriel", "Franz, Robert",
|
|
57
|
+
"Gonzaga, Chiquinha", "Grandval, Clemence de", "Grieg, Edvard", "Handel, George Frideric",
|
|
58
|
+
"Haydn, Joseph", "Hensel, Fanny", "Holmes, Augusta Mary Anne", "Jaell, Marie",
|
|
59
|
+
"Kinkel, Johanna", "Kralik, Mathilde", "Lang, Josephine", "Lehmann, Liza",
|
|
60
|
+
"Liszt, Franz", "Mayer, Emilie", "Medtner, Nikolay", "Mendelssohn, Felix",
|
|
61
|
+
"Mozart, Wolfgang Amadeus", "Munktell, Helena", "Paradis, Maria Theresia von",
|
|
62
|
+
"Parratt, Walter", "Prokofiev, Sergey", "Rachmaninoff, Sergei", "Ravel, Maurice",
|
|
63
|
+
"Reichardt, Louise", "Saint-Georges, Joseph Bologne", "Saint-Saens, Camille",
|
|
64
|
+
"Satie, Erik", "Scarlatti, Domenico", "Schroter, Corona", "Schubert, Franz",
|
|
65
|
+
"Schumann, Clara", "Schumann, Robert", "Scriabin, Aleksandr", "Shostakovich, Dmitry",
|
|
66
|
+
"Sibelius, Jean", "Smetana, Bedrich", "Tchaikovsky, Pyotr", "Viardot, Pauline",
|
|
67
|
+
"Vivaldi, Antonio", "Warlock, Peter", "Wolf, Hugo", "Zumsteeg, Emilie",
|
|
68
|
+
]);
|
|
69
|
+
|
|
42
70
|
const ABC_PHONET_MAP: Record<string, Phonet> = {
|
|
43
71
|
"C": Phonet.c, "D": Phonet.d, "E": Phonet.e, "F": Phonet.f, "G": Phonet.g, "A": Phonet.a, "B": Phonet.b,
|
|
44
72
|
"c": Phonet.c, "d": Phonet.d, "e": Phonet.e, "f": Phonet.f, "g": Phonet.g, "a": Phonet.a, "b": Phonet.b,
|
|
@@ -286,12 +314,20 @@ const convertKeySignature = (abcKey: ABC.KeySignature): KeySignature => {
|
|
|
286
314
|
* Convert ABC clef string to Lilylet Clef
|
|
287
315
|
*/
|
|
288
316
|
const convertClef = (clefStr: string): Clef | undefined => {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
317
|
+
// Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
|
|
318
|
+
// ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
|
|
319
|
+
// LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
|
|
320
|
+
const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
|
|
321
|
+
const base = shift ? shift[1] : clefStr;
|
|
322
|
+
let resolved: string | undefined;
|
|
323
|
+
switch (base?.toLowerCase()) {
|
|
324
|
+
case "treble": resolved = "treble"; break;
|
|
325
|
+
case "bass": resolved = "bass"; break;
|
|
326
|
+
case "alto": case "tenor": resolved = "alto"; break;
|
|
293
327
|
default: return undefined;
|
|
294
328
|
}
|
|
329
|
+
if (shift) resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
|
|
330
|
+
return resolved as Clef;
|
|
295
331
|
};
|
|
296
332
|
|
|
297
333
|
/**
|
|
@@ -323,9 +359,60 @@ interface StaffAssignment {
|
|
|
323
359
|
|
|
324
360
|
/**
|
|
325
361
|
* Parse %%score layout to determine voice→(part, staff) mapping.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
362
|
+
*
|
|
363
|
+
* Grouping semantics (recursive, any nesting depth):
|
|
364
|
+
* [ ... ] square: each child becomes a separate part -> serialized with \\\
|
|
365
|
+
* ( ... ) arc: all voices share one staff in one part -> voices joined with \\
|
|
366
|
+
* { ... } curly: one part, each child is a separate staff -> grand staff
|
|
367
|
+
* leaf a bare voice number "1" (often wrapped as {items:["1"]}) -> one part, one staff
|
|
368
|
+
*
|
|
369
|
+
* The AST wraps each leaf voice one level deep, e.g. ( 1 2 ) becomes
|
|
370
|
+
* {bound:'arc', items:[{items:['1']},{items:['2']}]}
|
|
371
|
+
* so leaves must be collected recursively rather than assuming a fixed depth.
|
|
328
372
|
*/
|
|
373
|
+
|
|
374
|
+
type ScoreNode = ABC.StaffGroup | string;
|
|
375
|
+
|
|
376
|
+
// All voice numbers under a node, flattened.
|
|
377
|
+
const collectScoreVoices = (node: ScoreNode): number[] => {
|
|
378
|
+
if (typeof node === "string") {
|
|
379
|
+
const v = parseInt(node, 10);
|
|
380
|
+
return isNaN(v) ? [] : [v];
|
|
381
|
+
}
|
|
382
|
+
const voices: number[] = [];
|
|
383
|
+
for (const item of node.items || []) {
|
|
384
|
+
voices.push(...collectScoreVoices(item));
|
|
385
|
+
}
|
|
386
|
+
return voices;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Expand a node into a list of parts; each part is a list of staves; each staff is a list of voices.
|
|
390
|
+
const scoreNodeToParts = (node: ScoreNode): number[][][] => {
|
|
391
|
+
if (typeof node === "string") {
|
|
392
|
+
const v = parseInt(node, 10);
|
|
393
|
+
return isNaN(v) ? [] : [[[v]]];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (node.bound === "square") {
|
|
397
|
+
// each child is its own part
|
|
398
|
+
const parts: number[][][] = [];
|
|
399
|
+
for (const child of node.items || []) {
|
|
400
|
+
parts.push(...scoreNodeToParts(child));
|
|
401
|
+
}
|
|
402
|
+
return parts;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (node.bound === "curly") {
|
|
406
|
+
// one part, each child is a separate staff (grand staff)
|
|
407
|
+
const staves = (node.items || []).map(child => collectScoreVoices(child)).filter(s => s.length > 0);
|
|
408
|
+
return staves.length > 0 ? [staves] : [];
|
|
409
|
+
}
|
|
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
|
+
};
|
|
415
|
+
|
|
329
416
|
const parseScoreLayout = (
|
|
330
417
|
headers: any[]
|
|
331
418
|
): Map<number, StaffAssignment> | null => {
|
|
@@ -336,78 +423,14 @@ const parseScoreLayout = (
|
|
|
336
423
|
const voiceMap = new Map<number, StaffAssignment>();
|
|
337
424
|
|
|
338
425
|
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++;
|
|
426
|
+
for (const top of layout) {
|
|
427
|
+
for (const part of scoreNodeToParts(top)) {
|
|
428
|
+
part.forEach((staff, staffIdx) => {
|
|
429
|
+
for (const voice of staff) {
|
|
430
|
+
voiceMap.set(voice, { partIndex, staffInPart: staffIdx + 1 });
|
|
361
431
|
}
|
|
362
|
-
}
|
|
432
|
+
});
|
|
363
433
|
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
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
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
434
|
}
|
|
412
435
|
}
|
|
413
436
|
|
|
@@ -878,7 +901,14 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
|
|
|
878
901
|
}
|
|
879
902
|
|
|
880
903
|
for (const h of headers) {
|
|
881
|
-
|
|
904
|
+
const comment = (h as any).comment;
|
|
905
|
+
if (typeof comment === "string") {
|
|
906
|
+
const value = comment.trim();
|
|
907
|
+
if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value)) metadata.genre = value;
|
|
908
|
+
else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value)) metadata.instrument = value;
|
|
909
|
+
else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value)) metadata.composer = value;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
882
912
|
if ((h as any).staffLayout) continue;
|
|
883
913
|
|
|
884
914
|
const header = h as { name: string; value: any };
|
|
@@ -79,6 +79,41 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
// Resolve a clef string into MEI shape/line plus optional octave displacement.
|
|
83
|
+
// Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
|
|
84
|
+
// the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
|
|
85
|
+
// and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
|
|
86
|
+
// and dis.place ("below" | "above").
|
|
87
|
+
const resolveClef = (clefStr: string): { shape: string; line: number; dis?: "8" | "15"; disPlace?: "above" | "below" } => {
|
|
88
|
+
const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
|
|
89
|
+
const base = match ? match[1] : clefStr;
|
|
90
|
+
const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
|
|
91
|
+
if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
|
|
92
|
+
return {
|
|
93
|
+
shape: clefInfo.shape,
|
|
94
|
+
line: clefInfo.line,
|
|
95
|
+
dis: match[3] as "8" | "15",
|
|
96
|
+
disPlace: match[2] === "^" ? "above" : "below",
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
101
|
+
const clefElementAttrs = (clefStr: string): string => {
|
|
102
|
+
const c = resolveClef(clefStr);
|
|
103
|
+
let attrs = `shape="${c.shape}" line="${c.line}"`;
|
|
104
|
+
if (c.dis) attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
|
|
105
|
+
return attrs;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Attributes for a <staffDef> clef (clef.* namespace).
|
|
109
|
+
const staffDefClefAttrs = (clefStr: string): string => {
|
|
110
|
+
const c = resolveClef(clefStr);
|
|
111
|
+
let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
|
|
112
|
+
if (c.dis) attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
|
|
113
|
+
return attrs;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
|
|
82
117
|
// Lilylet duration division to MEI dur
|
|
83
118
|
// division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
|
|
84
119
|
const DURATIONS: Record<number, string> = {
|
|
@@ -178,11 +213,13 @@ const getKeyAccidentals = (fifths: number): Record<string, string> => {
|
|
|
178
213
|
// The written pitch should be adjusted by subtracting the ottava shift
|
|
179
214
|
// measureAccidentals: tracks accidentals used earlier in the same measure (keyed by "pname-oct")
|
|
180
215
|
// - mutated to record new accidentals; used to add cancellation naturals
|
|
181
|
-
const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): { pname: string; oct: number; accid?: string; accidGes?: string } => {
|
|
216
|
+
const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): { pname: string; oct: number; octGes?: number; accid?: string; accidGes?: string } => {
|
|
182
217
|
// Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
|
|
183
218
|
// When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
|
|
184
219
|
// For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
|
|
185
|
-
const
|
|
220
|
+
const soundingOct = 4 + pitch.octave;
|
|
221
|
+
const oct = soundingOct - ottavaShift;
|
|
222
|
+
const octGes = ottavaShift !== 0 ? soundingOct : undefined;
|
|
186
223
|
|
|
187
224
|
// Get the accidental implied by the key signature for this note
|
|
188
225
|
const keyAccidentals = getKeyAccidentals(keyFifths);
|
|
@@ -239,7 +276,7 @@ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number =
|
|
|
239
276
|
}
|
|
240
277
|
}
|
|
241
278
|
|
|
242
|
-
return { pname: pitch.phonet, oct, accid, accidGes };
|
|
279
|
+
return { pname: pitch.phonet, oct, octGes, accid, accidGes };
|
|
243
280
|
};
|
|
244
281
|
|
|
245
282
|
|
|
@@ -255,7 +292,7 @@ const tremoloToStemMod = (division: number): string | undefined => {
|
|
|
255
292
|
|
|
256
293
|
// Build note element
|
|
257
294
|
const buildNoteElement = (
|
|
258
|
-
pitch: { pname: string; oct: number; accid?: string; accidGes?: string },
|
|
295
|
+
pitch: { pname: string; oct: number; octGes?: number; accid?: string; accidGes?: string },
|
|
259
296
|
dur: string,
|
|
260
297
|
dots: number,
|
|
261
298
|
indent: string,
|
|
@@ -278,6 +315,7 @@ const buildNoteElement = (
|
|
|
278
315
|
attrs += ` dur="${dur}"`;
|
|
279
316
|
}
|
|
280
317
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
318
|
+
if (pitch.octGes !== undefined) attrs += ` oct.ges="${pitch.octGes}"`;
|
|
281
319
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
282
320
|
if (!inChord && dots > 0) attrs += ` dots="${dots}"`;
|
|
283
321
|
if (!inChord && options.grace) attrs += ` grace="unacc"`;
|
|
@@ -754,8 +792,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
754
792
|
const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
|
|
755
793
|
if (effectiveStaffNum === layerStaffNum) {
|
|
756
794
|
const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
757
|
-
|
|
758
|
-
xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
795
|
+
xml += `${clefIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
|
|
759
796
|
}
|
|
760
797
|
activeClef = ctx.clef;
|
|
761
798
|
endingClef = ctx.clef;
|
|
@@ -801,6 +838,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
801
838
|
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
|
|
802
839
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
803
840
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
841
|
+
if (pitch.octGes !== undefined) attrs += ` oct.ges="${pitch.octGes}"`;
|
|
804
842
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
805
843
|
result += `${indent} <note ${attrs} />\n`;
|
|
806
844
|
} else if (event.pitchA.length > 1) {
|
|
@@ -809,6 +847,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
809
847
|
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
810
848
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
811
849
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
850
|
+
if (pitch.octGes !== undefined) attrs += ` oct.ges="${pitch.octGes}"`;
|
|
812
851
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
813
852
|
result += `${indent} <note ${attrs} />\n`;
|
|
814
853
|
}
|
|
@@ -820,6 +859,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
820
859
|
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
|
|
821
860
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
822
861
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
862
|
+
if (pitch.octGes !== undefined) attrs += ` oct.ges="${pitch.octGes}"`;
|
|
823
863
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
824
864
|
result += `${indent} <note ${attrs} />\n`;
|
|
825
865
|
} else if (event.pitchB.length > 1) {
|
|
@@ -828,6 +868,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
828
868
|
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
829
869
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
830
870
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
871
|
+
if (pitch.octGes !== undefined) attrs += ` oct.ges="${pitch.octGes}"`;
|
|
831
872
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
832
873
|
result += `${indent} <note ${attrs} />\n`;
|
|
833
874
|
}
|
|
@@ -1299,8 +1340,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1299
1340
|
if (ctx.clef && ctx.clef !== currentClef) {
|
|
1300
1341
|
const layerStaff = voice.staff || 1;
|
|
1301
1342
|
if (currentStaff === layerStaff) {
|
|
1302
|
-
|
|
1303
|
-
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
1343
|
+
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
|
|
1304
1344
|
}
|
|
1305
1345
|
currentClef = ctx.clef;
|
|
1306
1346
|
}
|
|
@@ -1952,16 +1992,14 @@ const encodeScoreDef = (
|
|
|
1952
1992
|
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1953
1993
|
const globalStaff = info.staffOffset + ls;
|
|
1954
1994
|
const clef = info.clefs[ls] || Clef.treble;
|
|
1955
|
-
|
|
1956
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
1995
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1957
1996
|
}
|
|
1958
1997
|
xml += `${indent} </staffGrp>\n`;
|
|
1959
1998
|
} else {
|
|
1960
1999
|
// Single staff part
|
|
1961
2000
|
const globalStaff = info.staffOffset + 1;
|
|
1962
2001
|
const clef = info.clefs[1] || Clef.treble;
|
|
1963
|
-
|
|
1964
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
2002
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1965
2003
|
}
|
|
1966
2004
|
}
|
|
1967
2005
|
|
|
@@ -383,7 +383,7 @@ const serializeContextChange = (event: ContextChange): string => {
|
|
|
383
383
|
|
|
384
384
|
// Clef
|
|
385
385
|
if (event.clef) {
|
|
386
|
-
parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
|
|
386
|
+
parts.push('\\clef "' + (CLEF_MAP[event.clef] ?? event.clef) + '"');
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
// Key signature
|
|
@@ -688,7 +688,7 @@ const serializeVoice = (
|
|
|
688
688
|
const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
|
|
689
689
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
690
690
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
691
|
-
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
691
|
+
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
692
692
|
if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
|
|
693
693
|
}
|
|
694
694
|
// Skip redundant clef context events if this staff's clef is already established
|
|
@@ -712,7 +712,7 @@ const serializeVoice = (
|
|
|
712
712
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
713
713
|
const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
|
|
714
714
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
715
|
-
parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
|
|
715
|
+
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
716
716
|
if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
|
|
717
717
|
}
|
|
718
718
|
continue;
|
|
@@ -736,7 +736,7 @@ const serializeVoice = (
|
|
|
736
736
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
737
737
|
const targetClef = allStaffClefs?.[activeStaff];
|
|
738
738
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
739
|
-
parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
|
|
739
|
+
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
740
740
|
if (emittedClefs) emittedClefs[activeStaff] = targetClef;
|
|
741
741
|
}
|
|
742
742
|
}
|
|
@@ -802,11 +802,16 @@ const serializePart = (
|
|
|
802
802
|
const voiceStrs: string[] = [];
|
|
803
803
|
let staff = currentStaff;
|
|
804
804
|
|
|
805
|
+
// A part is a grand staff only if its voices span more than one staff.
|
|
806
|
+
// Only then do we force \staff on every voice; single-staff parts emit \staff
|
|
807
|
+
// solely when the staff actually changes (e.g. resetting after a prior grand staff).
|
|
808
|
+
const partIsGrandStaff = new Set(part.voices.map(v => v.staff)).size > 1;
|
|
809
|
+
|
|
805
810
|
for (let i = 0; i < part.voices.length; i++) {
|
|
806
811
|
const voice = part.voices[i];
|
|
807
812
|
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
808
813
|
const isFirstVoice = isFirstPart && i === 0;
|
|
809
|
-
const { str, newStaff } = serializeVoice(voice, staff,
|
|
814
|
+
const { str, newStaff } = serializeVoice(voice, staff, partIsGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
|
|
810
815
|
voiceStrs.push(str);
|
|
811
816
|
staff = newStaff;
|
|
812
817
|
}
|
|
@@ -825,8 +830,8 @@ const serializeMeasure = (
|
|
|
825
830
|
isGrandStaff: boolean = false,
|
|
826
831
|
currentKey?: KeySignature,
|
|
827
832
|
currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
|
|
828
|
-
|
|
829
|
-
|
|
833
|
+
partStaffClefs?: Record<number, Record<number, Clef>>,
|
|
834
|
+
partEmittedClefs?: Record<number, Record<number, Clef>>
|
|
830
835
|
): { str: string; newStaff: number } => {
|
|
831
836
|
const parts: string[] = [];
|
|
832
837
|
|
|
@@ -838,13 +843,15 @@ const serializeMeasure = (
|
|
|
838
843
|
time: currentTime,
|
|
839
844
|
};
|
|
840
845
|
|
|
841
|
-
//
|
|
842
|
-
|
|
846
|
+
// Per-part clef state: each part has its own staff→clef maps so that distinct
|
|
847
|
+
// parts sharing staff number 1 do not clobber each other's clefs.
|
|
848
|
+
const clefsFor = (pi: number) => partStaffClefs?.[pi] || {};
|
|
849
|
+
const emittedFor = (pi: number) => partEmittedClefs?.[pi] || (partEmittedClefs ? (partEmittedClefs[pi] = {}) : {});
|
|
843
850
|
|
|
844
851
|
// Parts
|
|
845
852
|
let staff = currentStaff;
|
|
846
853
|
if (measure.parts.length === 1) {
|
|
847
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true,
|
|
854
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsFor(0), emittedFor(0));
|
|
848
855
|
if (partStr) {
|
|
849
856
|
parts.push(partStr);
|
|
850
857
|
}
|
|
@@ -855,7 +862,7 @@ const serializeMeasure = (
|
|
|
855
862
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
856
863
|
const part = measure.parts[i];
|
|
857
864
|
// Pass measureContext to all parts, isFirstPart to first part only
|
|
858
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0,
|
|
865
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsFor(i), emittedFor(i));
|
|
859
866
|
if (str) {
|
|
860
867
|
partStrs.push(str);
|
|
861
868
|
}
|
|
@@ -892,6 +899,12 @@ const serializeMetadata = (metadata: Metadata): string => {
|
|
|
892
899
|
if (metadata.lyricist) {
|
|
893
900
|
lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
|
|
894
901
|
}
|
|
902
|
+
if (metadata.genre) {
|
|
903
|
+
lines.push('[genre "' + escapeString(metadata.genre) + '"]');
|
|
904
|
+
}
|
|
905
|
+
if (metadata.instrument) {
|
|
906
|
+
lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
|
|
907
|
+
}
|
|
895
908
|
if (metadata.autoBeam) {
|
|
896
909
|
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
897
910
|
}
|
|
@@ -929,8 +942,11 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
929
942
|
let currentStaff = 1; // Parser starts at staff 1
|
|
930
943
|
let currentKey: KeySignature | undefined;
|
|
931
944
|
let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
|
|
932
|
-
|
|
933
|
-
|
|
945
|
+
// Clefs are tracked per part (each part is an independent instrument). Voice `staff`
|
|
946
|
+
// numbers are staff-within-part, so distinct parts may both use staff 1 — keying clef
|
|
947
|
+
// state by staff alone would conflate them. Outer key = part index, inner key = staff.
|
|
948
|
+
const partStaffClefs: Record<number, Record<number, Clef>> = {};
|
|
949
|
+
const partEmittedClefs: Record<number, Record<number, Clef>> = {};
|
|
934
950
|
|
|
935
951
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
936
952
|
const measure = doc.measures[i];
|
|
@@ -942,8 +958,9 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
942
958
|
currentTime = measure.timeSig;
|
|
943
959
|
}
|
|
944
960
|
|
|
945
|
-
// Collect clefs from this measure's voices
|
|
946
|
-
|
|
961
|
+
// Collect clefs from this measure's voices, per part
|
|
962
|
+
measure.parts.forEach((part, pi) => {
|
|
963
|
+
const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
|
|
947
964
|
for (const voice of part.voices) {
|
|
948
965
|
let clefActiveStaff = voice.staff;
|
|
949
966
|
for (const event of voice.events) {
|
|
@@ -958,9 +975,9 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
958
975
|
}
|
|
959
976
|
}
|
|
960
977
|
}
|
|
961
|
-
}
|
|
978
|
+
});
|
|
962
979
|
|
|
963
|
-
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime,
|
|
980
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
964
981
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
965
982
|
measureStrs.push(measureStr || 's1');
|
|
966
983
|
currentStaff = newStaff;
|