@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.
@@ -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
- switch (clefStr?.toLowerCase()) {
290
- case "treble": return Clef.treble;
291
- case "bass": return Clef.bass;
292
- case "alto": case "tenor": return Clef.alto;
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
- * {(...) | (...)} = one part with two staves
327
- * (...) = voices sharing one staff
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
- for (const group of layout) {
341
- if (group.bound === "curly") {
342
- // Curly braces = one instrument/part with multiple staves
343
- let staffInPart = 1;
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
- if ((h as any).comment) continue;
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 oct = 4 + pitch.octave - ottavaShift;
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
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
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
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
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
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
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
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
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, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
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
- staffClefs?: Record<number, Clef>,
829
- emittedClefs?: Record<number, Clef>
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
- // Pass staffClefs to parts for per-voice clef lookup
842
- const clefsByStaff = staffClefs || {};
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, clefsByStaff, emittedClefs);
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, clefsByStaff, emittedClefs);
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
- const staffClefs: Record<number, Clef> = {}; // Track clef per staff
933
- const emittedClefs: Record<number, Clef> = {}; // Track which clefs have been output
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
- for (const part of measure.parts) {
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, staffClefs, emittedClefs);
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;