@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
|
@@ -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 = {
|
|
@@ -504,7 +538,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
504
538
|
// Convert RestEvent to MEI
|
|
505
539
|
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
|
|
506
540
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
507
|
-
|
|
541
|
+
const restId = generateId('rest');
|
|
542
|
+
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
508
543
|
if (event.duration.dots > 0)
|
|
509
544
|
attrs += ` dots="${event.duration.dots}"`;
|
|
510
545
|
// Cross-staff attribute
|
|
@@ -517,13 +552,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
517
552
|
}
|
|
518
553
|
// Space rest (invisible)
|
|
519
554
|
if (event.invisible) {
|
|
520
|
-
return `${indent}<space ${attrs} />\n
|
|
555
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
521
556
|
}
|
|
522
557
|
// Full measure rest
|
|
523
558
|
if (event.fullMeasure) {
|
|
524
|
-
|
|
559
|
+
const mRestId = generateId('mrest');
|
|
560
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
525
561
|
}
|
|
526
|
-
return `${indent}<rest ${attrs} />\n
|
|
562
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
527
563
|
};
|
|
528
564
|
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
529
565
|
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
@@ -611,7 +647,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
611
647
|
}
|
|
612
648
|
else if (e.type === 'rest') {
|
|
613
649
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
614
|
-
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
650
|
+
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
|
|
615
651
|
}
|
|
616
652
|
else if (e.type === 'context') {
|
|
617
653
|
const ctx = e;
|
|
@@ -620,8 +656,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
620
656
|
const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
|
|
621
657
|
if (effectiveStaffNum === layerStaffNum) {
|
|
622
658
|
const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
623
|
-
|
|
624
|
-
xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
659
|
+
xml += `${clefIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
|
|
625
660
|
}
|
|
626
661
|
activeClef = ctx.clef;
|
|
627
662
|
endingClef = ctx.clef;
|
|
@@ -776,6 +811,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
776
811
|
const barlines = [];
|
|
777
812
|
const markups = [];
|
|
778
813
|
const pendingMarkups = [];
|
|
814
|
+
const pendingDynamics = [];
|
|
779
815
|
// Track current stem direction from context changes
|
|
780
816
|
let currentStemDirection = undefined;
|
|
781
817
|
// Track current staff for cross-staff notation
|
|
@@ -791,6 +827,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
791
827
|
}
|
|
792
828
|
pendingMarkups.length = 0;
|
|
793
829
|
};
|
|
830
|
+
// Helper to flush pending leading dynamics onto a note ID
|
|
831
|
+
const flushPendingDynamics = (noteId) => {
|
|
832
|
+
for (const label of pendingDynamics) {
|
|
833
|
+
dynamics.push({ startid: noteId, label });
|
|
834
|
+
}
|
|
835
|
+
pendingDynamics.length = 0;
|
|
836
|
+
};
|
|
794
837
|
// Helper to check if pitches match for tie continuation
|
|
795
838
|
const pitchesMatch = (p1, p2) => {
|
|
796
839
|
if (p1.length !== p2.length)
|
|
@@ -851,6 +894,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
851
894
|
}
|
|
852
895
|
// Flush any pending markups onto this note
|
|
853
896
|
flushPendingMarkups(result.elementId);
|
|
897
|
+
// Flush any pending leading dynamics onto this note
|
|
898
|
+
flushPendingDynamics(result.elementId);
|
|
854
899
|
// If there's a pending ottava, start the span on this note
|
|
855
900
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
856
901
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -963,7 +1008,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
963
1008
|
case 'rest': {
|
|
964
1009
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
965
1010
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
966
|
-
|
|
1011
|
+
const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1012
|
+
xml += restResult.xml;
|
|
1013
|
+
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1014
|
+
flushPendingMarkups(restResult.elementId);
|
|
1015
|
+
flushPendingDynamics(restResult.elementId);
|
|
1016
|
+
lastNoteId = restResult.elementId;
|
|
967
1017
|
break;
|
|
968
1018
|
}
|
|
969
1019
|
case 'tuplet':
|
|
@@ -979,6 +1029,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
979
1029
|
// Flush any pending markups onto the first note of the tuplet
|
|
980
1030
|
if (tupletResult.firstNoteId) {
|
|
981
1031
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1032
|
+
flushPendingDynamics(tupletResult.firstNoteId);
|
|
982
1033
|
lastNoteId = tupletResult.firstNoteId;
|
|
983
1034
|
}
|
|
984
1035
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
@@ -1014,8 +1065,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1014
1065
|
if (ctx.clef && ctx.clef !== currentClef) {
|
|
1015
1066
|
const layerStaff = voice.staff || 1;
|
|
1016
1067
|
if (currentStaff === layerStaff) {
|
|
1017
|
-
|
|
1018
|
-
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
1068
|
+
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
|
|
1019
1069
|
}
|
|
1020
1070
|
currentClef = ctx.clef;
|
|
1021
1071
|
}
|
|
@@ -1095,6 +1145,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1095
1145
|
}
|
|
1096
1146
|
}
|
|
1097
1147
|
break;
|
|
1148
|
+
case 'dynamic':
|
|
1149
|
+
{
|
|
1150
|
+
// Standalone (leading) dynamic - attaches to the following note
|
|
1151
|
+
const dynEvent = event;
|
|
1152
|
+
pendingDynamics.push(dynEvent.dynamicType);
|
|
1153
|
+
}
|
|
1154
|
+
break;
|
|
1098
1155
|
}
|
|
1099
1156
|
// Close beam element if beam ends
|
|
1100
1157
|
if (beamEnd) {
|
|
@@ -1580,8 +1637,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
|
|
|
1580
1637
|
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1581
1638
|
const globalStaff = info.staffOffset + ls;
|
|
1582
1639
|
const clef = info.clefs[ls] || Clef.treble;
|
|
1583
|
-
|
|
1584
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
1640
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1585
1641
|
}
|
|
1586
1642
|
xml += `${indent} </staffGrp>\n`;
|
|
1587
1643
|
}
|
|
@@ -1589,8 +1645,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
|
|
|
1589
1645
|
// Single staff part
|
|
1590
1646
|
const globalStaff = info.staffOffset + 1;
|
|
1591
1647
|
const clef = info.clefs[1] || Clef.treble;
|
|
1592
|
-
|
|
1593
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
1648
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1594
1649
|
}
|
|
1595
1650
|
}
|
|
1596
1651
|
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) {
|
|
@@ -455,12 +455,25 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
455
455
|
return serializeTremoloEvent(event, env);
|
|
456
456
|
case 'barline':
|
|
457
457
|
return { str: serializeBarlineEvent(event), newEnv: env };
|
|
458
|
+
case 'markup': {
|
|
459
|
+
const mk = event;
|
|
460
|
+
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
461
|
+
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
462
|
+
}
|
|
463
|
+
case 'dynamic': {
|
|
464
|
+
const dynStr = DYNAMIC_MAP[event.dynamicType];
|
|
465
|
+
return { str: dynStr || '', newEnv: env };
|
|
466
|
+
}
|
|
458
467
|
default:
|
|
459
468
|
return { str: '', newEnv: env };
|
|
460
469
|
}
|
|
461
470
|
};
|
|
462
|
-
// Find
|
|
471
|
+
// Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
|
|
472
|
+
// a clef context event that appears before any musical event on the home staff.
|
|
473
|
+
// A clef that first appears AFTER a note is a mid-voice change and must be emitted
|
|
474
|
+
// inline where it occurs, not hoisted to the front — so it is not returned here.
|
|
463
475
|
const findVoiceClef = (voice) => {
|
|
476
|
+
const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
464
477
|
let activeStaff = voice.staff;
|
|
465
478
|
for (const event of voice.events) {
|
|
466
479
|
if (event.type === 'context') {
|
|
@@ -471,6 +484,11 @@ const findVoiceClef = (voice) => {
|
|
|
471
484
|
return ctx.clef;
|
|
472
485
|
}
|
|
473
486
|
}
|
|
487
|
+
else if (MUSICAL.has(event.type)) {
|
|
488
|
+
// Reached music on the home staff before any clef — no leading clef.
|
|
489
|
+
if (activeStaff === voice.staff)
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
474
492
|
}
|
|
475
493
|
return undefined;
|
|
476
494
|
};
|
|
@@ -490,7 +508,6 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
490
508
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
491
509
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
492
510
|
// context{staff} events before this index are skipped in the main loop.
|
|
493
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
494
511
|
let effectiveInitialStaff = voice.staff;
|
|
495
512
|
let leadStaffScanEnd = 0;
|
|
496
513
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -516,8 +533,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
516
533
|
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
517
534
|
continue;
|
|
518
535
|
}
|
|
519
|
-
|
|
520
|
-
|
|
536
|
+
// Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
|
|
537
|
+
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
538
|
+
break;
|
|
521
539
|
}
|
|
522
540
|
// Output staff command if voice staff differs from current parser staff,
|
|
523
541
|
// or always output if it's a grand staff score for clarity.
|
|
@@ -545,16 +563,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
545
563
|
parts.push('\\time ' + numerator + '/' + denominator);
|
|
546
564
|
}
|
|
547
565
|
}
|
|
548
|
-
// Output clef only if not yet emitted or changed for this staff
|
|
549
|
-
|
|
566
|
+
// Output clef only if not yet emitted or changed for this staff.
|
|
567
|
+
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
568
|
+
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
569
|
+
// hoisted here — it is emitted inline at its position.
|
|
570
|
+
const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
|
|
550
571
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
551
572
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
552
|
-
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
573
|
+
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
553
574
|
if (emittedClefs)
|
|
554
575
|
emittedClefs[voice.staff] = voiceClef;
|
|
555
576
|
}
|
|
556
|
-
// Skip redundant clef context events if this staff's clef is already established
|
|
557
|
-
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
558
577
|
let activeStaff = effectiveInitialStaff;
|
|
559
578
|
let activeStemDir;
|
|
560
579
|
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
@@ -572,7 +591,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
572
591
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
573
592
|
const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
|
|
574
593
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
575
|
-
parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
|
|
594
|
+
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
576
595
|
if (emittedClefs)
|
|
577
596
|
emittedClefs[activeStaff] = ctxClef;
|
|
578
597
|
}
|
|
@@ -581,8 +600,11 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
581
600
|
if (ctx.staff && !ctx.clef && !ctx.ottava)
|
|
582
601
|
continue; // same staff, pure no-op
|
|
583
602
|
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
584
|
-
// Skip clef-only context
|
|
585
|
-
|
|
603
|
+
// Skip a clef-only context event only if it is REDUNDANT — i.e. it restates the
|
|
604
|
+
// clef already active for this staff. A clef that differs is a genuine change and
|
|
605
|
+
// must be emitted inline at its position.
|
|
606
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
|
|
607
|
+
emittedClefs?.[ctx.staff || activeStaff] === ctx.clef) {
|
|
586
608
|
continue;
|
|
587
609
|
}
|
|
588
610
|
}
|
|
@@ -596,7 +618,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
596
618
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
597
619
|
const targetClef = allStaffClefs?.[activeStaff];
|
|
598
620
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
599
|
-
parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
|
|
621
|
+
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
600
622
|
if (emittedClefs)
|
|
601
623
|
emittedClefs[activeStaff] = targetClef;
|
|
602
624
|
}
|
|
@@ -651,11 +673,15 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
|
|
|
651
673
|
}
|
|
652
674
|
const voiceStrs = [];
|
|
653
675
|
let staff = currentStaff;
|
|
676
|
+
// A part is a grand staff only if its voices span more than one staff.
|
|
677
|
+
// Only then do we force \staff on every voice; single-staff parts emit \staff
|
|
678
|
+
// solely when the staff actually changes (e.g. resetting after a prior grand staff).
|
|
679
|
+
const partIsGrandStaff = new Set(part.voices.map(v => v.staff)).size > 1;
|
|
654
680
|
for (let i = 0; i < part.voices.length; i++) {
|
|
655
681
|
const voice = part.voices[i];
|
|
656
682
|
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
657
683
|
const isFirstVoice = isFirstPart && i === 0;
|
|
658
|
-
const { str, newStaff } = serializeVoice(voice, staff,
|
|
684
|
+
const { str, newStaff } = serializeVoice(voice, staff, partIsGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
|
|
659
685
|
voiceStrs.push(str);
|
|
660
686
|
staff = newStaff;
|
|
661
687
|
}
|
|
@@ -664,7 +690,7 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
|
|
|
664
690
|
};
|
|
665
691
|
// Serialize a measure, tracking staff state across parts
|
|
666
692
|
// Always output key/time at start of each measure
|
|
667
|
-
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime,
|
|
693
|
+
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, partStaffClefs, partEmittedClefs) => {
|
|
668
694
|
const parts = [];
|
|
669
695
|
// Build measure context for all voices (key/time)
|
|
670
696
|
// Key and time are written to first voice, clef to all voices based on staff
|
|
@@ -673,12 +699,14 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
673
699
|
key: currentKey,
|
|
674
700
|
time: currentTime,
|
|
675
701
|
};
|
|
676
|
-
//
|
|
677
|
-
|
|
702
|
+
// Per-part clef state: each part has its own staff→clef maps so that distinct
|
|
703
|
+
// parts sharing staff number 1 do not clobber each other's clefs.
|
|
704
|
+
const clefsFor = (pi) => partStaffClefs?.[pi] || {};
|
|
705
|
+
const emittedFor = (pi) => partEmittedClefs?.[pi] || (partEmittedClefs ? (partEmittedClefs[pi] = {}) : {});
|
|
678
706
|
// Parts
|
|
679
707
|
let staff = currentStaff;
|
|
680
708
|
if (measure.parts.length === 1) {
|
|
681
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true,
|
|
709
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsFor(0), emittedFor(0));
|
|
682
710
|
if (partStr) {
|
|
683
711
|
parts.push(partStr);
|
|
684
712
|
}
|
|
@@ -690,7 +718,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
690
718
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
691
719
|
const part = measure.parts[i];
|
|
692
720
|
// Pass measureContext to all parts, isFirstPart to first part only
|
|
693
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0,
|
|
721
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsFor(i), emittedFor(i));
|
|
694
722
|
if (str) {
|
|
695
723
|
partStrs.push(str);
|
|
696
724
|
}
|
|
@@ -722,6 +750,12 @@ const serializeMetadata = (metadata) => {
|
|
|
722
750
|
if (metadata.lyricist) {
|
|
723
751
|
lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
|
|
724
752
|
}
|
|
753
|
+
if (metadata.genre) {
|
|
754
|
+
lines.push('[genre "' + escapeString(metadata.genre) + '"]');
|
|
755
|
+
}
|
|
756
|
+
if (metadata.instrument) {
|
|
757
|
+
lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
|
|
758
|
+
}
|
|
725
759
|
if (metadata.autoBeam) {
|
|
726
760
|
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
727
761
|
}
|
|
@@ -749,8 +783,11 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
749
783
|
let currentStaff = 1; // Parser starts at staff 1
|
|
750
784
|
let currentKey;
|
|
751
785
|
let currentTime;
|
|
752
|
-
|
|
753
|
-
|
|
786
|
+
// Clefs are tracked per part (each part is an independent instrument). Voice `staff`
|
|
787
|
+
// numbers are staff-within-part, so distinct parts may both use staff 1 — keying clef
|
|
788
|
+
// state by staff alone would conflate them. Outer key = part index, inner key = staff.
|
|
789
|
+
const partStaffClefs = {};
|
|
790
|
+
const partEmittedClefs = {};
|
|
754
791
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
755
792
|
const measure = doc.measures[i];
|
|
756
793
|
// Update current key/time if measure has them
|
|
@@ -760,24 +797,31 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
760
797
|
if (measure.timeSig) {
|
|
761
798
|
currentTime = measure.timeSig;
|
|
762
799
|
}
|
|
763
|
-
// Collect clefs from this measure's voices
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (
|
|
774
|
-
|
|
800
|
+
// Collect clefs from this measure's voices, per part — but only AFTER serializing
|
|
801
|
+
// the measure, so that during serialization allStaffClefs reflects the clef state
|
|
802
|
+
// CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
|
|
803
|
+
// to the measure's front; it is emitted inline at its position).
|
|
804
|
+
const collectClefs = () => {
|
|
805
|
+
measure.parts.forEach((part, pi) => {
|
|
806
|
+
const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
|
|
807
|
+
for (const voice of part.voices) {
|
|
808
|
+
let clefActiveStaff = voice.staff;
|
|
809
|
+
for (const event of voice.events) {
|
|
810
|
+
if (event.type === 'context') {
|
|
811
|
+
const ctx = event;
|
|
812
|
+
if (ctx.staff) {
|
|
813
|
+
clefActiveStaff = ctx.staff;
|
|
814
|
+
}
|
|
815
|
+
if (ctx.clef) {
|
|
816
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
817
|
+
}
|
|
775
818
|
}
|
|
776
819
|
}
|
|
777
820
|
}
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime,
|
|
821
|
+
});
|
|
822
|
+
};
|
|
823
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
824
|
+
collectClefs();
|
|
781
825
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
782
826
|
measureStrs.push(measureStr || 's1');
|
|
783
827
|
currentStaff = newStaff;
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -221,7 +221,11 @@ export interface MarkupEvent {
|
|
|
221
221
|
content: string;
|
|
222
222
|
placement?: Placement;
|
|
223
223
|
}
|
|
224
|
-
export
|
|
224
|
+
export interface DynamicEvent {
|
|
225
|
+
type: 'dynamic';
|
|
226
|
+
dynamicType: DynamicType;
|
|
227
|
+
}
|
|
228
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
|
|
225
229
|
export interface Voice {
|
|
226
230
|
staff: number;
|
|
227
231
|
events: Event[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
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",
|
|
@@ -30,12 +30,14 @@
|
|
|
30
30
|
"test": "tsx ./tests/parser.ts",
|
|
31
31
|
"test:mei": "tsx ./tests/mei.ts",
|
|
32
32
|
"test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
|
|
33
|
-
"test:unit": "tsx ./tests/unit/encodePitch.test.ts",
|
|
34
33
|
"test:partial": "tsx ./tests/unit/partialWarning.test.ts",
|
|
35
34
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
36
35
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
37
36
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
38
37
|
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
38
|
+
"train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
|
|
39
|
+
"build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
|
|
40
|
+
"test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
|
|
39
41
|
"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
42
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
41
43
|
"ts": "tsx"
|
package/source/abc/abc.jison
CHANGED
|
@@ -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';
|
|
@@ -238,7 +239,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
238
239
|
<key_signature>"Loc" return 'NAME';
|
|
239
240
|
<key_signature>"HP" return 'NAME';
|
|
240
241
|
<key_signature>"Hp" return 'NAME';
|
|
241
|
-
<key_signature>[a-z]+[ \t]
|
|
242
|
+
<key_signature>[a-z]+[ \t]*[=][^\n\]]* {}
|
|
242
243
|
<key_signature>[A-G] return 'A';
|
|
243
244
|
<key_signature>[A-Z][a-z]+ return 'NAME';
|
|
244
245
|
<key_signature>[b] return 'FLAT';
|
|
@@ -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>[(
|
|
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
|
|
@@ -542,7 +555,6 @@ music
|
|
|
542
555
|
| music N -> $1
|
|
543
556
|
| music NAME -> $1
|
|
544
557
|
| music '^' NAME -> $1
|
|
545
|
-
| music '^' -> $1
|
|
546
558
|
| music '[' N -> $1
|
|
547
559
|
;
|
|
548
560
|
|
|
@@ -586,6 +598,8 @@ articulation_content
|
|
|
586
598
|
| a -> articulation($1)
|
|
587
599
|
| "^" -> articulation($1)
|
|
588
600
|
| fingering_numbers -> ({fingering: Number($1)})
|
|
601
|
+
| '(' fingering_numbers ')' -> ({fingering: Number($2)})
|
|
602
|
+
| '(' fingering_numbers -> ({fingering: Number($2)})
|
|
589
603
|
| tremolo -> ({tremolo: $1})
|
|
590
604
|
| tremolo '-' -> ({tremolo: $1}) // unknown meaning of '-'?
|
|
591
605
|
;
|