@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.
@@ -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, Clef, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
7
+ import { Phonet, Accidental, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, } 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,
@@ -237,13 +264,28 @@ const convertKeySignature = (abcKey) => {
237
264
  * Convert ABC clef string to Lilylet Clef
238
265
  */
239
266
  const convertClef = (clefStr) => {
240
- switch (clefStr?.toLowerCase()) {
241
- case "treble": return Clef.treble;
242
- case "bass": return Clef.bass;
267
+ // Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
268
+ // ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
269
+ // LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
270
+ const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
271
+ const base = shift ? shift[1] : clefStr;
272
+ let resolved;
273
+ switch (base?.toLowerCase()) {
274
+ case "treble":
275
+ resolved = "treble";
276
+ break;
277
+ case "bass":
278
+ resolved = "bass";
279
+ break;
243
280
  case "alto":
244
- case "tenor": return Clef.alto;
281
+ case "tenor":
282
+ resolved = "alto";
283
+ break;
245
284
  default: return undefined;
246
285
  }
286
+ if (shift)
287
+ resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
288
+ return resolved;
247
289
  };
248
290
  /**
249
291
  * Convert ABC barline to Lilylet barline style
@@ -267,11 +309,41 @@ const convertBarline = (bar) => {
267
309
  return "|";
268
310
  }
269
311
  };
270
- /**
271
- * Parse %%score layout to determine voice→(part, staff) mapping.
272
- * {(...) | (...)} = one part with two staves
273
- * (...) = voices sharing one staff
274
- */
312
+ // All voice numbers under a node, flattened.
313
+ const collectScoreVoices = (node) => {
314
+ if (typeof node === "string") {
315
+ const v = parseInt(node, 10);
316
+ return isNaN(v) ? [] : [v];
317
+ }
318
+ const voices = [];
319
+ for (const item of node.items || []) {
320
+ voices.push(...collectScoreVoices(item));
321
+ }
322
+ return voices;
323
+ };
324
+ // Expand a node into a list of parts; each part is a list of staves; each staff is a list of voices.
325
+ const scoreNodeToParts = (node) => {
326
+ if (typeof node === "string") {
327
+ const v = parseInt(node, 10);
328
+ return isNaN(v) ? [] : [[[v]]];
329
+ }
330
+ if (node.bound === "square") {
331
+ // each child is its own part
332
+ const parts = [];
333
+ for (const child of node.items || []) {
334
+ parts.push(...scoreNodeToParts(child));
335
+ }
336
+ return parts;
337
+ }
338
+ if (node.bound === "curly") {
339
+ // one part, each child is a separate staff (grand staff)
340
+ const staves = (node.items || []).map(child => collectScoreVoices(child)).filter(s => s.length > 0);
341
+ return staves.length > 0 ? [staves] : [];
342
+ }
343
+ // arc or no bound: one part, all voices on a single staff
344
+ const voices = collectScoreVoices(node);
345
+ return voices.length > 0 ? [[voices]] : [];
346
+ };
275
347
  const parseScoreLayout = (headers) => {
276
348
  const layoutHeader = headers.find((h) => h.staffLayout);
277
349
  if (!layoutHeader)
@@ -279,86 +351,15 @@ const parseScoreLayout = (headers) => {
279
351
  const layout = layoutHeader.staffLayout;
280
352
  const voiceMap = new Map();
281
353
  let partIndex = 0;
282
- for (const group of layout) {
283
- if (group.bound === "curly") {
284
- // Curly braces = one instrument/part with multiple staves
285
- let staffInPart = 1;
286
- for (const item of group.items) {
287
- if (typeof item === "string") {
288
- voiceMap.set(parseInt(item), { partIndex, staffInPart });
354
+ for (const top of layout) {
355
+ for (const part of scoreNodeToParts(top)) {
356
+ part.forEach((staff, staffIdx) => {
357
+ for (const voice of staff) {
358
+ voiceMap.set(voice, { partIndex, staffInPart: staffIdx + 1 });
289
359
  }
290
- else if (item.items) {
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
- }
360
+ });
323
361
  partIndex++;
324
362
  }
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
363
  }
363
364
  return voiceMap.size > 0 ? voiceMap : null;
364
365
  };
@@ -790,8 +791,17 @@ const decodeTune = (tune) => {
790
791
  }
791
792
  }
792
793
  for (const h of headers) {
793
- if (h.comment)
794
+ const comment = h.comment;
795
+ if (typeof comment === "string") {
796
+ const value = comment.trim();
797
+ if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value))
798
+ metadata.genre = value;
799
+ else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value))
800
+ metadata.instrument = value;
801
+ else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value))
802
+ metadata.composer = value;
794
803
  continue;
804
+ }
795
805
  if (h.staffLayout)
796
806
  continue;
797
807
  const header = h;
@@ -44,6 +44,40 @@ const CLEF_SHAPES = {
44
44
  F: { shape: "F", line: 4 },
45
45
  C: { shape: "C", line: 3 },
46
46
  };
47
+ // Resolve a clef string into MEI shape/line plus optional octave displacement.
48
+ // Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
49
+ // the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
50
+ // and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
51
+ // and dis.place ("below" | "above").
52
+ const resolveClef = (clefStr) => {
53
+ const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
54
+ const base = match ? match[1] : clefStr;
55
+ const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
56
+ if (!match)
57
+ return { shape: clefInfo.shape, line: clefInfo.line };
58
+ return {
59
+ shape: clefInfo.shape,
60
+ line: clefInfo.line,
61
+ dis: match[3],
62
+ disPlace: match[2] === "^" ? "above" : "below",
63
+ };
64
+ };
65
+ // Attributes for a standalone <clef> element (mid-measure clef change).
66
+ const clefElementAttrs = (clefStr) => {
67
+ const c = resolveClef(clefStr);
68
+ let attrs = `shape="${c.shape}" line="${c.line}"`;
69
+ if (c.dis)
70
+ attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
71
+ return attrs;
72
+ };
73
+ // Attributes for a <staffDef> clef (clef.* namespace).
74
+ const staffDefClefAttrs = (clefStr) => {
75
+ const c = resolveClef(clefStr);
76
+ let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
77
+ if (c.dis)
78
+ attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
79
+ return attrs;
80
+ };
47
81
  // Lilylet duration division to MEI dur
48
82
  // division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
49
83
  const DURATIONS = {
@@ -128,7 +162,9 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals)
128
162
  // Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
129
163
  // When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
130
164
  // For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
131
- const oct = 4 + pitch.octave - ottavaShift;
165
+ const soundingOct = 4 + pitch.octave;
166
+ const oct = soundingOct - ottavaShift;
167
+ const octGes = ottavaShift !== 0 ? soundingOct : undefined;
132
168
  // Get the accidental implied by the key signature for this note
133
169
  const keyAccidentals = getKeyAccidentals(keyFifths);
134
170
  const keyAccid = keyAccidentals[pitch.phonet];
@@ -189,7 +225,7 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals)
189
225
  measureAccidentals.set(pitchKey, 'n');
190
226
  }
191
227
  }
192
- return { pname: pitch.phonet, oct, accid, accidGes };
228
+ return { pname: pitch.phonet, oct, octGes, accid, accidGes };
193
229
  };
194
230
  // Convert tremolo division to stem.mod value
195
231
  const tremoloToStemMod = (division) => {
@@ -209,6 +245,8 @@ const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteI
209
245
  }
210
246
  if (pitch.accid)
211
247
  attrs += ` accid="${pitch.accid}"`;
248
+ if (pitch.octGes !== undefined)
249
+ attrs += ` oct.ges="${pitch.octGes}"`;
212
250
  if (pitch.accidGes)
213
251
  attrs += ` accid.ges="${pitch.accidGes}"`;
214
252
  if (!inChord && dots > 0)
@@ -616,8 +654,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
616
654
  const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
617
655
  if (effectiveStaffNum === layerStaffNum) {
618
656
  const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
619
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
620
- xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
657
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
621
658
  }
622
659
  activeClef = ctx.clef;
623
660
  endingClef = ctx.clef;
@@ -655,6 +692,8 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measur
655
692
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
656
693
  if (pitch.accid)
657
694
  attrs += ` accid="${pitch.accid}"`;
695
+ if (pitch.octGes !== undefined)
696
+ attrs += ` oct.ges="${pitch.octGes}"`;
658
697
  if (pitch.accidGes)
659
698
  attrs += ` accid.ges="${pitch.accidGes}"`;
660
699
  result += `${indent} <note ${attrs} />\n`;
@@ -666,6 +705,8 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measur
666
705
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
667
706
  if (pitch.accid)
668
707
  attrs += ` accid="${pitch.accid}"`;
708
+ if (pitch.octGes !== undefined)
709
+ attrs += ` oct.ges="${pitch.octGes}"`;
669
710
  if (pitch.accidGes)
670
711
  attrs += ` accid.ges="${pitch.accidGes}"`;
671
712
  result += `${indent} <note ${attrs} />\n`;
@@ -678,6 +719,8 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measur
678
719
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
679
720
  if (pitch.accid)
680
721
  attrs += ` accid="${pitch.accid}"`;
722
+ if (pitch.octGes !== undefined)
723
+ attrs += ` oct.ges="${pitch.octGes}"`;
681
724
  if (pitch.accidGes)
682
725
  attrs += ` accid.ges="${pitch.accidGes}"`;
683
726
  result += `${indent} <note ${attrs} />\n`;
@@ -689,6 +732,8 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measur
689
732
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
690
733
  if (pitch.accid)
691
734
  attrs += ` accid="${pitch.accid}"`;
735
+ if (pitch.octGes !== undefined)
736
+ attrs += ` oct.ges="${pitch.octGes}"`;
692
737
  if (pitch.accidGes)
693
738
  attrs += ` accid.ges="${pitch.accidGes}"`;
694
739
  result += `${indent} <note ${attrs} />\n`;
@@ -1002,8 +1047,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1002
1047
  if (ctx.clef && ctx.clef !== currentClef) {
1003
1048
  const layerStaff = voice.staff || 1;
1004
1049
  if (currentStaff === layerStaff) {
1005
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
1006
- xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
1050
+ xml += `${currentIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
1007
1051
  }
1008
1052
  currentClef = ctx.clef;
1009
1053
  }
@@ -1568,8 +1612,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1568
1612
  for (let ls = 1; ls <= info.maxStaff; ls++) {
1569
1613
  const globalStaff = info.staffOffset + ls;
1570
1614
  const clef = info.clefs[ls] || Clef.treble;
1571
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1572
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1615
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1573
1616
  }
1574
1617
  xml += `${indent} </staffGrp>\n`;
1575
1618
  }
@@ -1577,8 +1620,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1577
1620
  // Single staff part
1578
1621
  const globalStaff = info.staffOffset + 1;
1579
1622
  const clef = info.clefs[1] || Clef.treble;
1580
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1581
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1623
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1582
1624
  }
1583
1625
  }
1584
1626
  xml += `${indent} </staffGrp>\n`;
@@ -286,7 +286,7 @@ const serializeContextChange = (event) => {
286
286
  const parts = [];
287
287
  // Clef
288
288
  if (event.clef) {
289
- parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
289
+ parts.push('\\clef "' + (CLEF_MAP[event.clef] ?? event.clef) + '"');
290
290
  }
291
291
  // Key signature
292
292
  if (event.key) {
@@ -549,7 +549,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
549
549
  const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
550
550
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
551
551
  if (voiceClef && !clefAlreadyEmitted) {
552
- parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
552
+ parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
553
553
  if (emittedClefs)
554
554
  emittedClefs[voice.staff] = voiceClef;
555
555
  }
@@ -572,7 +572,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
572
572
  // Emit target staff clef if the event carries one or allStaffClefs knows it
573
573
  const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
574
574
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
575
- parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
575
+ parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
576
576
  if (emittedClefs)
577
577
  emittedClefs[activeStaff] = ctxClef;
578
578
  }
@@ -596,7 +596,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
596
596
  // Emit the target staff's clef if it differs from what was last emitted for this staff
597
597
  const targetClef = allStaffClefs?.[activeStaff];
598
598
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
599
- parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
599
+ parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
600
600
  if (emittedClefs)
601
601
  emittedClefs[activeStaff] = targetClef;
602
602
  }
@@ -651,11 +651,15 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
651
651
  }
652
652
  const voiceStrs = [];
653
653
  let staff = currentStaff;
654
+ // A part is a grand staff only if its voices span more than one staff.
655
+ // Only then do we force \staff on every voice; single-staff parts emit \staff
656
+ // solely when the staff actually changes (e.g. resetting after a prior grand staff).
657
+ const partIsGrandStaff = new Set(part.voices.map(v => v.staff)).size > 1;
654
658
  for (let i = 0; i < part.voices.length; i++) {
655
659
  const voice = part.voices[i];
656
660
  // Pass measureContext to all voices, isFirstVoice for key/time
657
661
  const isFirstVoice = isFirstPart && i === 0;
658
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
662
+ const { str, newStaff } = serializeVoice(voice, staff, partIsGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
659
663
  voiceStrs.push(str);
660
664
  staff = newStaff;
661
665
  }
@@ -664,7 +668,7 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
664
668
  };
665
669
  // Serialize a measure, tracking staff state across parts
666
670
  // Always output key/time at start of each measure
667
- const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs, emittedClefs) => {
671
+ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, partStaffClefs, partEmittedClefs) => {
668
672
  const parts = [];
669
673
  // Build measure context for all voices (key/time)
670
674
  // Key and time are written to first voice, clef to all voices based on staff
@@ -673,12 +677,14 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
673
677
  key: currentKey,
674
678
  time: currentTime,
675
679
  };
676
- // Pass staffClefs to parts for per-voice clef lookup
677
- const clefsByStaff = staffClefs || {};
680
+ // Per-part clef state: each part has its own staff→clef maps so that distinct
681
+ // parts sharing staff number 1 do not clobber each other's clefs.
682
+ const clefsFor = (pi) => partStaffClefs?.[pi] || {};
683
+ const emittedFor = (pi) => partEmittedClefs?.[pi] || (partEmittedClefs ? (partEmittedClefs[pi] = {}) : {});
678
684
  // Parts
679
685
  let staff = currentStaff;
680
686
  if (measure.parts.length === 1) {
681
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
687
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsFor(0), emittedFor(0));
682
688
  if (partStr) {
683
689
  parts.push(partStr);
684
690
  }
@@ -690,7 +696,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
690
696
  for (let i = 0; i < measure.parts.length; i++) {
691
697
  const part = measure.parts[i];
692
698
  // Pass measureContext to all parts, isFirstPart to first part only
693
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
699
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsFor(i), emittedFor(i));
694
700
  if (str) {
695
701
  partStrs.push(str);
696
702
  }
@@ -722,6 +728,12 @@ const serializeMetadata = (metadata) => {
722
728
  if (metadata.lyricist) {
723
729
  lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
724
730
  }
731
+ if (metadata.genre) {
732
+ lines.push('[genre "' + escapeString(metadata.genre) + '"]');
733
+ }
734
+ if (metadata.instrument) {
735
+ lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
736
+ }
725
737
  if (metadata.autoBeam) {
726
738
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
727
739
  }
@@ -749,8 +761,11 @@ export const serializeLilyletDoc = (doc) => {
749
761
  let currentStaff = 1; // Parser starts at staff 1
750
762
  let currentKey;
751
763
  let currentTime;
752
- const staffClefs = {}; // Track clef per staff
753
- const emittedClefs = {}; // Track which clefs have been output
764
+ // Clefs are tracked per part (each part is an independent instrument). Voice `staff`
765
+ // numbers are staff-within-part, so distinct parts may both use staff 1 — keying clef
766
+ // state by staff alone would conflate them. Outer key = part index, inner key = staff.
767
+ const partStaffClefs = {};
768
+ const partEmittedClefs = {};
754
769
  for (let i = 0; i < doc.measures.length; i++) {
755
770
  const measure = doc.measures[i];
756
771
  // Update current key/time if measure has them
@@ -760,8 +775,9 @@ export const serializeLilyletDoc = (doc) => {
760
775
  if (measure.timeSig) {
761
776
  currentTime = measure.timeSig;
762
777
  }
763
- // Collect clefs from this measure's voices
764
- for (const part of measure.parts) {
778
+ // Collect clefs from this measure's voices, per part
779
+ measure.parts.forEach((part, pi) => {
780
+ const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
765
781
  for (const voice of part.voices) {
766
782
  let clefActiveStaff = voice.staff;
767
783
  for (const event of voice.events) {
@@ -776,8 +792,8 @@ export const serializeLilyletDoc = (doc) => {
776
792
  }
777
793
  }
778
794
  }
779
- }
780
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
795
+ });
796
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
781
797
  // Always include measure, even if empty (use space rest for empty measures)
782
798
  measureStrs.push(measureStr || 's1');
783
799
  currentStaff = newStaff;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -36,6 +36,9 @@
36
36
  "test:abc": "tsx ./tests/abc-decoder.ts",
37
37
  "test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
38
38
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
39
+ "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
40
+ "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
41
+ "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
39
42
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
40
43
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
41
44
  "ts": "tsx"
@@ -229,6 +229,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
229
229
  <key_signature>"treble" return 'TREBLE';
230
230
  <key_signature>"bass" return 'BASS';
231
231
  <key_signature>"tenor" return 'TENOR';
232
+ <key_signature>"alto" return 'ALTO';
232
233
  <key_signature>"none" return 'NAME';
233
234
  <key_signature>"Dor" return 'NAME';
234
235
  <key_signature>"Phr" return 'NAME';
@@ -256,14 +257,16 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
256
257
  <comment>[^\n]+ { return 'COMMENT'; }
257
258
  <comment>\n { this.popState(); }
258
259
  <spec_comment_name>[ \t]+ {}
259
- <spec_comment_name>"score" { this.popState(); this.pushState('spec_comment'); return 'SCORE'; }
260
- <spec_comment_name>"staves" { this.popState(); this.pushState('spec_comment'); return 'SCORE'; }
260
+ <spec_comment_name>"score" { this.popState(); this.pushState('spec_comment'); this._scoreDepth = 0; return 'SCORE'; }
261
+ <spec_comment_name>"staves" { this.popState(); this.pushState('spec_comment'); this._scoreDepth = 0; return 'SCORE'; }
261
262
  <spec_comment_name>[\w]+ { this.popState(); this.pushState('spec_comment_skip'); }
262
263
  <spec_comment_name>\n { this.popState(); this.popState(); }
263
264
  <spec_comment>[ \t]+ {}
264
- <spec_comment>[(){}\[\]|] return yytext
265
+ <spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
266
+ <spec_comment>[)\]}] { this._scoreDepth = (this._scoreDepth || 0) - 1; return yytext; }
267
+ <spec_comment>[|] return yytext
265
268
  <spec_comment>[\w]+ return 'NN'
266
- <spec_comment>\n { this.popState(); this.popState(); return 'LAYOUT_END'; }
269
+ <spec_comment>\n { if (this._scoreDepth > 0) { /* layout continues on next line */ } else { this.popState(); this.popState(); return 'LAYOUT_END'; } }
267
270
  <spec_comment_skip>[^\n]+ {}
268
271
  <spec_comment_skip>\n { this.popState(); this.popState(); }
269
272
 
@@ -399,6 +402,7 @@ staff_shift
399
402
  key_signature
400
403
  : key_root -> $1
401
404
  | NAME -> key(null, $1)
405
+ | clef -> $1
402
406
  ;
403
407
 
404
408
  key_root
@@ -412,6 +416,11 @@ clef
412
416
  : TREBLE -> clef($1)
413
417
  | BASS -> clef($1)
414
418
  | TENOR -> clef($1)
419
+ | ALTO -> clef($1)
420
+ | TREBLE plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
421
+ | BASS plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
422
+ | TENOR plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
423
+ | ALTO plus_minus_number -> clef($1 + ($2 < 0 ? '-' : '+') + Math.abs($2))
415
424
  ;
416
425
 
417
426
  sharp_or_flat
@@ -460,8 +469,11 @@ voice_exp
460
469
  : number -> voice($1)
461
470
  | number NAME -> voice($1, $2)
462
471
  | number NAME assigns -> voice($1, $2, $3)
472
+ | number NAME plus_minus_number -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3))
473
+ | number NAME plus_minus_number assigns -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3), $4)
463
474
  | NAME -> voice(1, $1)
464
475
  | NAME assigns -> voice(1, $1, $2)
476
+ | NAME plus_minus_number assigns -> voice(1, $1, $3)
465
477
  | upper_phonet number -> voice(1, $1 + String($2))
466
478
  | upper_phonet number assigns -> voice(1, $1 + String($2), $3)
467
479
  | upper_phonet number NAME -> voice(1, $1 + String($2))
@@ -524,6 +536,7 @@ bar
524
536
  | ':' '|' '|' ':' -> ':|:'
525
537
  | '|' '|' -> '||'
526
538
  | '|' ']' -> '|]'
539
+ | '|' ']' ':' -> '|]'
527
540
  | ':' '|' ']' -> ':|]'
528
541
  | '|' N -> '|' + $2
529
542
  | ':' '|' N -> ':|' + $2
@@ -586,6 +599,8 @@ articulation_content
586
599
  | a -> articulation($1)
587
600
  | "^" -> articulation($1)
588
601
  | fingering_numbers -> ({fingering: Number($1)})
602
+ | '(' fingering_numbers ')' -> ({fingering: Number($2)})
603
+ | '(' fingering_numbers -> ({fingering: Number($2)})
589
604
  | tremolo -> ({tremolo: $1})
590
605
  | tremolo '-' -> ({tremolo: $1}) // unknown meaning of '-'?
591
606
  ;