@k-l-lambda/lilylet 0.1.60 → 0.1.63
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 +300 -187
- package/lib/lilylet/abcDecoder.js +40 -12
- package/lib/lilylet/grammar.jison.js +273 -169
- package/lib/lilylet/lilypondDecoder.js +163 -39
- package/lib/lilylet/lilypondEncoder.js +120 -7
- package/lib/lilylet/meiEncoder.js +29 -8
- package/lib/lilylet/musicXmlEncoder.js +1 -1
- package/lib/lilylet/parser.d.ts +12 -1
- package/lib/lilylet/parser.js +11 -1
- package/lib/lilylet/serializer.js +33 -4
- package/lib/lilylet/types.d.ts +8 -2
- package/package.json +16 -8
- package/source/abc/TODO.md +97 -0
- package/source/abc/abc.jison +90 -15
- package/source/abc/grammar.jison.js +300 -187
- package/source/lilylet/abcDecoder.ts +42 -14
- package/source/lilylet/grammar.jison.js +273 -169
- package/source/lilylet/lilylet.jison +114 -15
- package/source/lilylet/lilypondDecoder.ts +139 -42
- package/source/lilylet/lilypondEncoder.ts +116 -9
- package/source/lilylet/meiEncoder.ts +32 -9
- package/source/lilylet/musicXmlDecoder.ts +2 -2
- package/source/lilylet/musicXmlEncoder.ts +1 -1
- package/source/lilylet/parser.ts +20 -0
- package/source/lilylet/serializer.ts +31 -6
- package/source/lilylet/types.ts +10 -2
|
@@ -391,6 +391,42 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
391
391
|
const measureMap = new Map();
|
|
392
392
|
const staffNames = [];
|
|
393
393
|
const interpreter = lilyDocument.interpret();
|
|
394
|
+
// Pre-compute partIndex for each track using staff-number-sequence heuristic.
|
|
395
|
+
// When staff numbers reset (decrease), a new part starts — this handles multi-PianoStaff
|
|
396
|
+
// scores where both pianos reuse the same staff names ("1" and "2").
|
|
397
|
+
// Scores that embed partIndex in the staff name ("1_1", "1_2", "2_1") are handled
|
|
398
|
+
// by parseStaffName; this heuristic only kicks in for plain numeric names.
|
|
399
|
+
let _seqPart = 1;
|
|
400
|
+
let _seqMaxStaff = 0;
|
|
401
|
+
const _trackPartIndices = interpreter.layoutMusic.musicTracks.map((track) => {
|
|
402
|
+
const staffName = track.contextDict?.Staff;
|
|
403
|
+
if (staffName && /^\d+_\d+$/.test(staffName)) {
|
|
404
|
+
// Staff name encodes partIndex explicitly — don't apply heuristic
|
|
405
|
+
return parseInt(staffName.split('_')[0], 10);
|
|
406
|
+
}
|
|
407
|
+
// PianoStaff is present (even as empty string "") when inside a grand-staff group;
|
|
408
|
+
// undefined means standalone instrument → different part from grand-staff tracks.
|
|
409
|
+
const hasPianoStaff = (track.contextDict?.PianoStaff !== undefined);
|
|
410
|
+
const staffNum = parseInt(staffName || '1', 10) || 1;
|
|
411
|
+
if (hasPianoStaff) {
|
|
412
|
+
// Grand-staff: new part if transitioning from standalone or staff resets
|
|
413
|
+
if (_seqMaxStaff > 0 && staffNum < _seqMaxStaff) {
|
|
414
|
+
_seqPart++;
|
|
415
|
+
_seqMaxStaff = 0;
|
|
416
|
+
}
|
|
417
|
+
else if (_seqMaxStaff === -1) {
|
|
418
|
+
// Transitioning from a standalone group
|
|
419
|
+
_seqPart++;
|
|
420
|
+
_seqMaxStaff = 0;
|
|
421
|
+
}
|
|
422
|
+
_seqMaxStaff = Math.max(_seqMaxStaff, staffNum);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
// Standalone: mark with -1 so next grand-staff group increments
|
|
426
|
+
_seqMaxStaff = -1;
|
|
427
|
+
}
|
|
428
|
+
return _seqPart;
|
|
429
|
+
});
|
|
394
430
|
interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
|
|
395
431
|
const appendStaff = (staffName) => {
|
|
396
432
|
if (!staffNames.includes(staffName)) {
|
|
@@ -399,15 +435,20 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
399
435
|
};
|
|
400
436
|
// Parse staff name to extract partIndex and staff number
|
|
401
437
|
// Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
|
|
402
|
-
// Falls back to
|
|
438
|
+
// Falls back to positional ordering for non-numeric names (e.g., "upper"→1, "lower"→2)
|
|
403
439
|
const parseStaffName = (name) => {
|
|
404
440
|
const match = name.match(/^(\d+)_(\d+)$/);
|
|
405
441
|
if (match) {
|
|
406
442
|
return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
|
|
407
443
|
}
|
|
408
|
-
// Fallback: single part, staff number from name or 1
|
|
409
444
|
const num = parseInt(name, 10);
|
|
410
|
-
|
|
445
|
+
if (!isNaN(num)) {
|
|
446
|
+
return { partIndex: 1, staffNum: num };
|
|
447
|
+
}
|
|
448
|
+
// Non-numeric name: assign staffNum by order of first appearance in staffNames
|
|
449
|
+
appendStaff(name);
|
|
450
|
+
const idx = staffNames.indexOf(name);
|
|
451
|
+
return { partIndex: 1, staffNum: idx + 1 };
|
|
411
452
|
};
|
|
412
453
|
// Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
|
|
413
454
|
// This won't be affected by \change Staff commands inside the track
|
|
@@ -415,16 +456,21 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
415
456
|
if (initialStaffName) {
|
|
416
457
|
appendStaff(initialStaffName);
|
|
417
458
|
}
|
|
418
|
-
|
|
459
|
+
// Empty string staff name ("") means unnamed staff — treat as staff 1 within its part
|
|
460
|
+
const parsedStaff = (initialStaffName != null && initialStaffName !== '')
|
|
461
|
+
? parseStaffName(initialStaffName)
|
|
462
|
+
: { partIndex: 1, staffNum: 1 };
|
|
419
463
|
// Use these as fixed values for this track - don't update from context.staffName
|
|
420
464
|
const trackStaff = parsedStaff.staffNum;
|
|
421
|
-
|
|
465
|
+
// Use sequence-based partIndex (detects multi-PianoStaff via staff number reset)
|
|
466
|
+
const trackPartIndex = _trackPartIndices[vi] ?? parsedStaff.partIndex;
|
|
422
467
|
// Track emitted context events across measures for this voice
|
|
423
468
|
let lastKey = undefined; // Track value changes (key fifths)
|
|
424
469
|
let lastTimeSig = undefined; // Track value changes (as string for comparison)
|
|
425
470
|
let lastClef = undefined; // Track value changes
|
|
426
471
|
let lastOttava = undefined; // Track value changes
|
|
427
472
|
let lastStemDirection = undefined; // Track value changes
|
|
473
|
+
let partialEmitted = false; // Emit \partial context once per track
|
|
428
474
|
const context = new lilyParser.TrackContext(undefined, {
|
|
429
475
|
listener: (term, context) => {
|
|
430
476
|
const mi = term._measure;
|
|
@@ -448,11 +494,12 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
448
494
|
};
|
|
449
495
|
}
|
|
450
496
|
const voice = measure.voices[vi];
|
|
451
|
-
// Update key/time from context on music events
|
|
497
|
+
// Update key/time/partial from context on music events
|
|
452
498
|
if (term instanceof lilyParser.MusicEvent ||
|
|
453
499
|
term instanceof lilyParser.LilyTerms.StemDirection ||
|
|
454
500
|
term instanceof lilyParser.LilyTerms.OctaveShift ||
|
|
455
|
-
term instanceof lilyParser.LilyTerms.Change
|
|
501
|
+
term instanceof lilyParser.LilyTerms.Change ||
|
|
502
|
+
term instanceof lilyParser.LilyTerms.Partial) {
|
|
456
503
|
if (context.key && measure.key === null) {
|
|
457
504
|
measure.key = context.key.key;
|
|
458
505
|
}
|
|
@@ -466,6 +513,32 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
466
513
|
measure.partial = true;
|
|
467
514
|
}
|
|
468
515
|
}
|
|
516
|
+
// Emit \partial context event when the Partial term fires (before partialDuration is cleared)
|
|
517
|
+
if (term instanceof lilyParser.LilyTerms.Partial && context.partialDuration && !partialEmitted) {
|
|
518
|
+
const mag = context.partialDuration.magnitude;
|
|
519
|
+
const WHOLE = 1920;
|
|
520
|
+
let division = 1, dots = 0;
|
|
521
|
+
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
522
|
+
const base = WHOLE / div;
|
|
523
|
+
if (Math.abs(mag - base) < 1) {
|
|
524
|
+
division = div;
|
|
525
|
+
dots = 0;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
if (Math.abs(mag - Math.round(base * 1.5)) < 1) {
|
|
529
|
+
division = div;
|
|
530
|
+
dots = 1;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
if (Math.abs(mag - Math.round(base * 1.75)) < 1) {
|
|
534
|
+
division = div;
|
|
535
|
+
dots = 2;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
voice.events.push({ type: 'context', partial: { division, dots } });
|
|
540
|
+
partialEmitted = true;
|
|
541
|
+
}
|
|
469
542
|
// Handle music events
|
|
470
543
|
if (term instanceof lilyParser.MusicEvent) {
|
|
471
544
|
// Staff is fixed per track (from track definition)
|
|
@@ -583,13 +656,17 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
583
656
|
}
|
|
584
657
|
// Process Rest
|
|
585
658
|
else if (term instanceof lilyParser.LilyTerms.Rest) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
659
|
+
// Ignore spacer rests inside grace contexts (e.g. \acciaccatura s8,
|
|
660
|
+
// \grace s4 — these are notation-only placeholders with no musical content)
|
|
661
|
+
if (!(term.isSpacer && context.inGrace)) {
|
|
662
|
+
const restEvent = {
|
|
663
|
+
type: 'rest',
|
|
664
|
+
duration: convertDuration(term.durationValue),
|
|
665
|
+
fullMeasure: (term.name === 'R') || undefined,
|
|
666
|
+
invisible: term.isSpacer || undefined,
|
|
667
|
+
};
|
|
668
|
+
voice.events.push(restEvent);
|
|
669
|
+
}
|
|
593
670
|
}
|
|
594
671
|
}
|
|
595
672
|
// Handle standalone stem direction (emit when value changes)
|
|
@@ -712,41 +789,80 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
712
789
|
// Handle tuplet
|
|
713
790
|
// Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
|
|
714
791
|
// remove the already-added notes and wrap them in a TupletEvent
|
|
715
|
-
else if (termAny.proto === 'Tuplet') {
|
|
716
|
-
const
|
|
717
|
-
const
|
|
792
|
+
else if (termAny.proto === 'Tuplet' || termAny.proto === 'Times') {
|
|
793
|
+
const isTimes = termAny.proto === 'Times';
|
|
794
|
+
const ratioStr = termAny.args?.[0]; // "3/2" for \tuplet, "2/3" for \times
|
|
795
|
+
// \tuplet supports an optional base-duration arg: \tuplet 3/2 4 { notes }
|
|
796
|
+
// making args = [ratio, baseDur?, body]. Use the last arg (= music block),
|
|
797
|
+
// matching what lotus Tuplet.music getter does: this.args[this.args.length-1]
|
|
798
|
+
const body = termAny.args?.[termAny.args.length - 1]?.body || [];
|
|
718
799
|
if (ratioStr && body.length > 0) {
|
|
719
|
-
// Parse ratio string
|
|
720
800
|
const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
|
|
721
801
|
if (ratioMatch) {
|
|
722
802
|
const [, num, denom] = ratioMatch;
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
803
|
+
// \tuplet 3/2: divider = 3/2, lilylet ratio = 2/3 → swap
|
|
804
|
+
// \times 2/3: factor = 2/3, lilylet ratio = 2/3 → no swap
|
|
805
|
+
const ratio = isTimes
|
|
806
|
+
? { numerator: parseInt(num, 10), denominator: parseInt(denom, 10) }
|
|
807
|
+
: { numerator: parseInt(denom, 10), denominator: parseInt(num, 10) };
|
|
808
|
+
// Count sounding (non-grace) notes/rests in the tuplet body,
|
|
809
|
+
// recursing into AfterGrace.body (main note) but not AfterGrace.grace.
|
|
810
|
+
// Grace / Acciaccatura / Appoggiatura blocks count as 0.
|
|
811
|
+
const countSounding = (items) => {
|
|
812
|
+
let n = 0;
|
|
813
|
+
for (const item of items) {
|
|
814
|
+
if (!item)
|
|
815
|
+
continue;
|
|
816
|
+
switch (item.proto) {
|
|
817
|
+
case 'Chord':
|
|
818
|
+
case 'Rest':
|
|
819
|
+
n++;
|
|
820
|
+
break;
|
|
821
|
+
case 'AfterGrace': {
|
|
822
|
+
// args[0] = main note, args[1] = after-grace notes (0-dur, skip)
|
|
823
|
+
const main = item.args?.[0];
|
|
824
|
+
if (main?.proto === 'Chord' || main?.proto === 'Rest')
|
|
825
|
+
n++;
|
|
826
|
+
else if (main?.body)
|
|
827
|
+
n += countSounding(main.body);
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
case 'Grace':
|
|
831
|
+
case 'Acciaccatura':
|
|
832
|
+
case 'Appoggiatura': break;
|
|
833
|
+
default:
|
|
834
|
+
if (item.body)
|
|
835
|
+
n += countSounding(item.body);
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return n;
|
|
726
840
|
};
|
|
727
|
-
|
|
728
|
-
const noteCount = body.filter((item) => item.proto === 'Chord' || item.proto === 'Rest').length;
|
|
729
|
-
// Remove the last noteCount note/rest events from voice.events
|
|
730
|
-
// (they were already added by the Chord/Rest handlers)
|
|
841
|
+
const noteCount = countSounding(body);
|
|
731
842
|
const tupletEvents = [];
|
|
732
843
|
let removed = 0;
|
|
844
|
+
// Pop notes/rests from voice.events, skipping context events.
|
|
845
|
+
// Grace notes are moved inside the tuplet but do NOT consume a
|
|
846
|
+
// sounding-note slot (they have 0 duration in the time signature).
|
|
733
847
|
while (removed < noteCount && voice.events.length > 0) {
|
|
734
848
|
const lastEvent = voice.events[voice.events.length - 1];
|
|
735
849
|
if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
|
|
736
850
|
tupletEvents.unshift(voice.events.pop());
|
|
737
|
-
|
|
851
|
+
if (!lastEvent.grace)
|
|
852
|
+
removed++;
|
|
853
|
+
}
|
|
854
|
+
else if (lastEvent.type === 'context' || lastEvent.type === 'pitchReset') {
|
|
855
|
+
// Context event between tuplet notes — move it inside tuplet too
|
|
856
|
+
tupletEvents.unshift(voice.events.pop());
|
|
738
857
|
}
|
|
739
858
|
else {
|
|
740
|
-
break;
|
|
859
|
+
break;
|
|
741
860
|
}
|
|
742
861
|
}
|
|
743
862
|
if (tupletEvents.length > 0) {
|
|
744
|
-
|
|
745
|
-
type: '
|
|
746
|
-
ratio,
|
|
747
|
-
events: tupletEvents,
|
|
748
|
-
};
|
|
749
|
-
voice.events.push(tupletEvent);
|
|
863
|
+
voice.events.push(isTimes
|
|
864
|
+
? { type: 'times', ratio, events: tupletEvents }
|
|
865
|
+
: { type: 'tuplet', ratio, events: tupletEvents });
|
|
750
866
|
}
|
|
751
867
|
}
|
|
752
868
|
}
|
|
@@ -812,7 +928,7 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
812
928
|
// AND no musical events (notes/rests/etc.) precede it — meaning the
|
|
813
929
|
// carry-over would be immediately cancelled with no effect.
|
|
814
930
|
const firstStaffCtxIdx = voice.events.findIndex(e => e.type === 'context' && e.staff != null);
|
|
815
|
-
const musicalTypes = new Set(['note', 'rest', 'tuplet', 'tremolo']);
|
|
931
|
+
const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
816
932
|
const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
|
|
817
933
|
voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
|
|
818
934
|
const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
|
|
@@ -822,12 +938,18 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
822
938
|
voice.events.unshift({ type: 'context', staff: carryStaff });
|
|
823
939
|
}
|
|
824
940
|
}
|
|
825
|
-
// Update carryStaff from this measure's events
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
941
|
+
// Update carryStaff from this measure's events (including inside tuplets)
|
|
942
|
+
const scanStaff = (events) => {
|
|
943
|
+
for (const e of events) {
|
|
944
|
+
if (e.type === 'context' && e.staff) {
|
|
945
|
+
carryStaff = e.staff;
|
|
946
|
+
}
|
|
947
|
+
else if ((e.type === 'tuplet' || e.type === 'times') && e.events) {
|
|
948
|
+
scanStaff(e.events);
|
|
949
|
+
}
|
|
829
950
|
}
|
|
830
|
-
}
|
|
951
|
+
};
|
|
952
|
+
scanStaff(voice.events);
|
|
831
953
|
}
|
|
832
954
|
}
|
|
833
955
|
});
|
|
@@ -849,6 +971,8 @@ const hasRealContent = (events) => {
|
|
|
849
971
|
return true;
|
|
850
972
|
if (e.type === 'tuplet')
|
|
851
973
|
return true;
|
|
974
|
+
if (e.type === 'times')
|
|
975
|
+
return true;
|
|
852
976
|
if (e.type === 'tremolo')
|
|
853
977
|
return true;
|
|
854
978
|
return false;
|
|
@@ -115,6 +115,59 @@ const getSpacerRest = (timeSig) => {
|
|
|
115
115
|
const { numerator, denominator } = timeSig;
|
|
116
116
|
return `s${denominator}*${numerator}`;
|
|
117
117
|
};
|
|
118
|
+
// === Partial Measure Helpers ===
|
|
119
|
+
const TPQN = 480;
|
|
120
|
+
const voiceDurationTicks = (voice) => {
|
|
121
|
+
let ticks = 0;
|
|
122
|
+
const addEvent = (e, factor = 1) => {
|
|
123
|
+
if (e.type === 'note' || e.type === 'rest') {
|
|
124
|
+
const ev = e;
|
|
125
|
+
if (ev.grace)
|
|
126
|
+
return;
|
|
127
|
+
let t = (TPQN * 4) / ev.duration.division;
|
|
128
|
+
let dot = t / 2;
|
|
129
|
+
for (let i = 0; i < ev.duration.dots; i++) {
|
|
130
|
+
t += dot;
|
|
131
|
+
dot /= 2;
|
|
132
|
+
}
|
|
133
|
+
ticks += Math.round(t * factor);
|
|
134
|
+
}
|
|
135
|
+
else if (e.type === 'tuplet' || e.type === 'times') {
|
|
136
|
+
const te = e;
|
|
137
|
+
const f = factor * te.ratio.numerator / te.ratio.denominator;
|
|
138
|
+
for (const inner of te.events)
|
|
139
|
+
addEvent(inner, f);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
for (const e of voice.events)
|
|
143
|
+
addEvent(e);
|
|
144
|
+
return ticks;
|
|
145
|
+
};
|
|
146
|
+
const ticksToLyDuration = (ticks) => {
|
|
147
|
+
const whole = TPQN * 4;
|
|
148
|
+
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
149
|
+
const t = whole / div;
|
|
150
|
+
if (Math.abs(ticks - t) < 1)
|
|
151
|
+
return String(div);
|
|
152
|
+
if (Math.abs(ticks - Math.round(t * 1.5)) < 1)
|
|
153
|
+
return `${div}.`;
|
|
154
|
+
if (Math.abs(ticks - Math.round(t * 1.75)) < 1)
|
|
155
|
+
return `${div}..`;
|
|
156
|
+
}
|
|
157
|
+
return '4';
|
|
158
|
+
};
|
|
159
|
+
// Return a spacer-rest suffix to pad a voice to the full measure duration.
|
|
160
|
+
// Uses s16*N to avoid complexity with dotted/compound values.
|
|
161
|
+
const padVoiceToMeasure = (voiceTicks, measureTicks) => {
|
|
162
|
+
const remaining = Math.round(measureTicks - voiceTicks);
|
|
163
|
+
if (remaining <= 0)
|
|
164
|
+
return '';
|
|
165
|
+
const sixteenth = Math.round(TPQN / 4); // 120 ticks
|
|
166
|
+
const numSixteenths = Math.round(remaining / sixteenth);
|
|
167
|
+
if (numSixteenths <= 0)
|
|
168
|
+
return '';
|
|
169
|
+
return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
|
|
170
|
+
};
|
|
118
171
|
/**
|
|
119
172
|
* Calculate the octave markers needed to serialize a pitch in relative mode.
|
|
120
173
|
*/
|
|
@@ -396,7 +449,11 @@ const encodeContextChange = (event) => {
|
|
|
396
449
|
* Encode a tuplet event
|
|
397
450
|
*/
|
|
398
451
|
const encodeTupletEvent = (event, env, lastDuration) => {
|
|
399
|
-
|
|
452
|
+
// \times preserves type:"times"; \tuplet is denominator/numerator
|
|
453
|
+
const header = event.type === 'times'
|
|
454
|
+
? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
|
|
455
|
+
: `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
|
|
456
|
+
let result = `${header} { `;
|
|
400
457
|
let newEnv = env;
|
|
401
458
|
let newDuration = lastDuration;
|
|
402
459
|
for (const subEvent of event.events) {
|
|
@@ -412,6 +469,9 @@ const encodeTupletEvent = (event, env, lastDuration) => {
|
|
|
412
469
|
newEnv = ne;
|
|
413
470
|
newDuration = nd;
|
|
414
471
|
}
|
|
472
|
+
else if (subEvent.type === 'context') {
|
|
473
|
+
result += encodeContextChange(subEvent) + ' ';
|
|
474
|
+
}
|
|
415
475
|
}
|
|
416
476
|
result += '}';
|
|
417
477
|
return { str: result, newEnv, newDuration };
|
|
@@ -519,7 +579,8 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
|
|
|
519
579
|
result += encodeContextChange(event) + ' ';
|
|
520
580
|
break;
|
|
521
581
|
}
|
|
522
|
-
case 'tuplet':
|
|
582
|
+
case 'tuplet':
|
|
583
|
+
case 'times': {
|
|
523
584
|
const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
|
|
524
585
|
result += str + ' ';
|
|
525
586
|
env = newEnv;
|
|
@@ -600,7 +661,7 @@ export const encode = (doc, options = {}) => {
|
|
|
600
661
|
for (const part of measure.parts) {
|
|
601
662
|
for (const voice of part.voices) {
|
|
602
663
|
for (const event of voice.events) {
|
|
603
|
-
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
|
|
664
|
+
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
604
665
|
return true;
|
|
605
666
|
}
|
|
606
667
|
}
|
|
@@ -622,6 +683,8 @@ export const encode = (doc, options = {}) => {
|
|
|
622
683
|
}
|
|
623
684
|
// Track time signature for each measure (for spacer rests)
|
|
624
685
|
const measureTimeSigs = [];
|
|
686
|
+
// Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
|
|
687
|
+
const measurePartialDurs = [];
|
|
625
688
|
let currentKey;
|
|
626
689
|
let currentTimeSig;
|
|
627
690
|
for (let mi = 0; mi < measures.length; mi++) {
|
|
@@ -633,6 +696,30 @@ export const encode = (doc, options = {}) => {
|
|
|
633
696
|
currentTimeSig = measure.timeSig;
|
|
634
697
|
// Store time signature for this measure
|
|
635
698
|
measureTimeSigs[mi] = currentTimeSig;
|
|
699
|
+
// Detect partial (pickup) measures and compute their duration.
|
|
700
|
+
// Only the first measure (mi===0) can be an implicit partial;
|
|
701
|
+
// subsequent incomplete measures are NOT treated as partial.
|
|
702
|
+
const isExplicitPartial = measure.partial === true;
|
|
703
|
+
if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
|
|
704
|
+
let maxVoiceTicks = 0;
|
|
705
|
+
for (const part of measure.parts) {
|
|
706
|
+
for (const v of part.voices) {
|
|
707
|
+
maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const expectedTicks = currentTimeSig
|
|
711
|
+
? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
|
|
712
|
+
: TPQN * 4;
|
|
713
|
+
if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
|
|
714
|
+
measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
measurePartialDurs[mi] = undefined;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
measurePartialDurs[mi] = undefined;
|
|
722
|
+
}
|
|
636
723
|
// Process each part
|
|
637
724
|
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
638
725
|
const part = measure.parts[pi];
|
|
@@ -649,11 +736,19 @@ export const encode = (doc, options = {}) => {
|
|
|
649
736
|
staffMeasures.push([]);
|
|
650
737
|
}
|
|
651
738
|
// Encode voice content
|
|
652
|
-
|
|
739
|
+
let voiceContent = encodeVoice(voice, {
|
|
653
740
|
key: currentKey,
|
|
654
741
|
timeSig: currentTimeSig,
|
|
655
742
|
isFirst: mi === 0
|
|
656
743
|
}, vi);
|
|
744
|
+
// For non-partial measures, pad incomplete voices to fill the full
|
|
745
|
+
// measure duration. lotus doesn't auto-advance on barlines, so
|
|
746
|
+
// under-full voices cause measure boundary miscounting.
|
|
747
|
+
if (!measurePartialDurs[mi] && currentTimeSig) {
|
|
748
|
+
const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
|
|
749
|
+
const voiceTicks = voiceDurationTicks(voice);
|
|
750
|
+
voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
|
|
751
|
+
}
|
|
657
752
|
staffMeasures[mi].push(voiceContent);
|
|
658
753
|
}
|
|
659
754
|
}
|
|
@@ -666,10 +761,28 @@ export const encode = (doc, options = {}) => {
|
|
|
666
761
|
// Build voice lines
|
|
667
762
|
const voiceLines = [];
|
|
668
763
|
for (let vi = 0; vi < maxVoices; vi++) {
|
|
764
|
+
let prevTimeSigStr;
|
|
669
765
|
const measureContents = measures.map((m, mi) => {
|
|
670
|
-
//
|
|
671
|
-
const
|
|
672
|
-
const
|
|
766
|
+
// For partial (pickup) measures, use a partial-duration spacer
|
|
767
|
+
const partialDur = measurePartialDurs[mi];
|
|
768
|
+
const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
|
|
769
|
+
let content = m[vi] || spacer;
|
|
770
|
+
// Inject \time if the content lacks it and the time sig changed.
|
|
771
|
+
// lotus processes each voice independently — without \time it
|
|
772
|
+
// defaults to 4/4, miscounting measure boundaries for other meters.
|
|
773
|
+
const ts = measureTimeSigs[mi];
|
|
774
|
+
if (ts) {
|
|
775
|
+
const tsStr = `${ts.numerator}/${ts.denominator}`;
|
|
776
|
+
if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
|
|
777
|
+
content = `${encodeTimeSig(ts)} ${content}`;
|
|
778
|
+
}
|
|
779
|
+
prevTimeSigStr = tsStr;
|
|
780
|
+
}
|
|
781
|
+
// For partial measures, prepend \partial DUR before all other commands
|
|
782
|
+
// so the lotus interpreter correctly tracks the pickup measure boundary.
|
|
783
|
+
if (partialDur && !content.includes('\\partial')) {
|
|
784
|
+
content = `\\partial ${partialDur} ${content}`;
|
|
785
|
+
}
|
|
673
786
|
// Wrap each measure in its own \relative c' to reset pitch context
|
|
674
787
|
return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
|
|
675
788
|
});
|
|
@@ -538,7 +538,7 @@ const tupletHasInternalBeams = (event) => {
|
|
|
538
538
|
return starts > 0 && starts === ends;
|
|
539
539
|
};
|
|
540
540
|
// Convert TupletEvent to MEI
|
|
541
|
-
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals) => {
|
|
541
|
+
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef) => {
|
|
542
542
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
543
543
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
544
544
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -561,6 +561,8 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
561
561
|
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
562
562
|
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
563
563
|
let beamOpen = false;
|
|
564
|
+
let activeClef = currentClef;
|
|
565
|
+
let endingClef;
|
|
564
566
|
for (const e of event.events) {
|
|
565
567
|
if (e.type === 'note') {
|
|
566
568
|
const noteEvent = e;
|
|
@@ -607,13 +609,27 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
607
609
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
608
610
|
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
609
611
|
}
|
|
612
|
+
else if (e.type === 'context') {
|
|
613
|
+
const ctx = e;
|
|
614
|
+
if (ctx.clef && ctx.clef !== activeClef) {
|
|
615
|
+
const layerStaffNum = layerStaff || 1;
|
|
616
|
+
const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
|
|
617
|
+
if (effectiveStaffNum === layerStaffNum) {
|
|
618
|
+
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`;
|
|
621
|
+
}
|
|
622
|
+
activeClef = ctx.clef;
|
|
623
|
+
endingClef = ctx.clef;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
610
626
|
}
|
|
611
627
|
// Close any unclosed beam
|
|
612
628
|
if (beamOpen) {
|
|
613
629
|
xml += `${baseIndent}</beam>\n`;
|
|
614
630
|
}
|
|
615
631
|
xml += `${indent}</tuplet>\n`;
|
|
616
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
632
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
|
|
617
633
|
};
|
|
618
634
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
619
635
|
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
@@ -688,7 +704,7 @@ const getEventBeamMarks = (event) => {
|
|
|
688
704
|
const markOptions = extractMarkOptions(event.marks);
|
|
689
705
|
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
690
706
|
}
|
|
691
|
-
if (event.type === 'tuplet') {
|
|
707
|
+
if (event.type === 'tuplet' || event.type === 'times') {
|
|
692
708
|
const tuplet = event;
|
|
693
709
|
// If the tuplet has internal beam groups, don't report beam marks to the parent
|
|
694
710
|
// so the parent won't wrap the tuplet in an external <beam>
|
|
@@ -923,11 +939,16 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
923
939
|
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
924
940
|
break;
|
|
925
941
|
}
|
|
926
|
-
case 'tuplet':
|
|
942
|
+
case 'tuplet':
|
|
943
|
+
case 'times': {
|
|
927
944
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
928
945
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
929
|
-
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
946
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
930
947
|
xml += tupletResult.xml;
|
|
948
|
+
// Propagate clef change from inside the tuplet to the parent tracker
|
|
949
|
+
if (tupletResult.endingClef) {
|
|
950
|
+
currentClef = tupletResult.endingClef;
|
|
951
|
+
}
|
|
931
952
|
// Flush any pending markups onto the first note of the tuplet
|
|
932
953
|
if (tupletResult.firstNoteId) {
|
|
933
954
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
@@ -1566,7 +1587,7 @@ const docHasBeamMarks = (doc) => {
|
|
|
1566
1587
|
}
|
|
1567
1588
|
}
|
|
1568
1589
|
}
|
|
1569
|
-
else if (event.type === 'tuplet') {
|
|
1590
|
+
else if (event.type === 'tuplet' || event.type === 'times') {
|
|
1570
1591
|
const tuplet = event;
|
|
1571
1592
|
for (const e of tuplet.events) {
|
|
1572
1593
|
if (e.type === 'note') {
|
|
@@ -1729,7 +1750,7 @@ const applyAutoBeamToVoice = (events, beamGroups) => {
|
|
|
1729
1750
|
flushRun();
|
|
1730
1751
|
position += dur;
|
|
1731
1752
|
}
|
|
1732
|
-
else if (event.type === 'tuplet') {
|
|
1753
|
+
else if (event.type === 'tuplet' || event.type === 'times') {
|
|
1733
1754
|
const tuplet = event;
|
|
1734
1755
|
const ratio = tuplet.ratio; // LilyPond ratio: num/den
|
|
1735
1756
|
// Check if all inner notes are beamable (division >= 8)
|
|
@@ -1907,7 +1928,7 @@ const encode = (doc, options = {}) => {
|
|
|
1907
1928
|
for (const event of voice.events) {
|
|
1908
1929
|
// Check for actual musical content (not just context changes or pitch resets)
|
|
1909
1930
|
if (event.type === 'note' || event.type === 'rest' ||
|
|
1910
|
-
event.type === 'tuplet' || event.type === 'tremolo') {
|
|
1931
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
1911
1932
|
return true;
|
|
1912
1933
|
}
|
|
1913
1934
|
}
|
|
@@ -567,7 +567,7 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
567
567
|
break;
|
|
568
568
|
}
|
|
569
569
|
case 'tuplet': {
|
|
570
|
-
const tupletEvents = event.events;
|
|
570
|
+
const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest');
|
|
571
571
|
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
572
572
|
const subEvent = tupletEvents[ti];
|
|
573
573
|
// Set tuplet ratio on duration so encodeDuration emits <time-modification>
|
package/lib/lilylet/parser.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
import { LilyletDoc } from "./types";
|
|
2
|
+
export interface ParseWarning {
|
|
3
|
+
type: 'partial-mismatch';
|
|
4
|
+
message: string;
|
|
5
|
+
declared: number;
|
|
6
|
+
actual: number;
|
|
7
|
+
}
|
|
2
8
|
declare const parseCode: (code: string) => LilyletDoc;
|
|
3
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Return warnings emitted during the most recent parseCode() call.
|
|
11
|
+
* Currently reports \partial duration mismatches.
|
|
12
|
+
*/
|
|
13
|
+
declare const getParseWarnings: () => ParseWarning[];
|
|
14
|
+
export { parseCode, getParseWarnings, };
|
package/lib/lilylet/parser.js
CHANGED
|
@@ -148,4 +148,14 @@ const parseCode = (code) => {
|
|
|
148
148
|
resolveDocumentPitches(raw);
|
|
149
149
|
return raw;
|
|
150
150
|
};
|
|
151
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Return warnings emitted during the most recent parseCode() call.
|
|
153
|
+
* Currently reports \partial duration mismatches.
|
|
154
|
+
*/
|
|
155
|
+
const getParseWarnings = () => {
|
|
156
|
+
if (parser && parser.getWarnings) {
|
|
157
|
+
return parser.getWarnings();
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
};
|
|
161
|
+
export { parseCode, getParseWarnings, };
|