@k-l-lambda/lilylet 0.1.60 → 0.1.62
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/lilylet/grammar.jison.js +273 -169
- package/lib/lilylet/lilypondDecoder.js +163 -39
- package/lib/lilylet/lilypondEncoder.js +117 -7
- 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 +12 -7
- package/source/abc/TODO.md +97 -0
- 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 +114 -9
- package/source/lilylet/meiEncoder.ts +2 -1
- 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) {
|
|
@@ -519,7 +576,8 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
|
|
|
519
576
|
result += encodeContextChange(event) + ' ';
|
|
520
577
|
break;
|
|
521
578
|
}
|
|
522
|
-
case 'tuplet':
|
|
579
|
+
case 'tuplet':
|
|
580
|
+
case 'times': {
|
|
523
581
|
const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
|
|
524
582
|
result += str + ' ';
|
|
525
583
|
env = newEnv;
|
|
@@ -600,7 +658,7 @@ export const encode = (doc, options = {}) => {
|
|
|
600
658
|
for (const part of measure.parts) {
|
|
601
659
|
for (const voice of part.voices) {
|
|
602
660
|
for (const event of voice.events) {
|
|
603
|
-
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
|
|
661
|
+
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
604
662
|
return true;
|
|
605
663
|
}
|
|
606
664
|
}
|
|
@@ -622,6 +680,8 @@ export const encode = (doc, options = {}) => {
|
|
|
622
680
|
}
|
|
623
681
|
// Track time signature for each measure (for spacer rests)
|
|
624
682
|
const measureTimeSigs = [];
|
|
683
|
+
// Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
|
|
684
|
+
const measurePartialDurs = [];
|
|
625
685
|
let currentKey;
|
|
626
686
|
let currentTimeSig;
|
|
627
687
|
for (let mi = 0; mi < measures.length; mi++) {
|
|
@@ -633,6 +693,30 @@ export const encode = (doc, options = {}) => {
|
|
|
633
693
|
currentTimeSig = measure.timeSig;
|
|
634
694
|
// Store time signature for this measure
|
|
635
695
|
measureTimeSigs[mi] = currentTimeSig;
|
|
696
|
+
// Detect partial (pickup) measures and compute their duration.
|
|
697
|
+
// Only the first measure (mi===0) can be an implicit partial;
|
|
698
|
+
// subsequent incomplete measures are NOT treated as partial.
|
|
699
|
+
const isExplicitPartial = measure.partial === true;
|
|
700
|
+
if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
|
|
701
|
+
let maxVoiceTicks = 0;
|
|
702
|
+
for (const part of measure.parts) {
|
|
703
|
+
for (const v of part.voices) {
|
|
704
|
+
maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const expectedTicks = currentTimeSig
|
|
708
|
+
? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
|
|
709
|
+
: TPQN * 4;
|
|
710
|
+
if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
|
|
711
|
+
measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
measurePartialDurs[mi] = undefined;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
measurePartialDurs[mi] = undefined;
|
|
719
|
+
}
|
|
636
720
|
// Process each part
|
|
637
721
|
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
638
722
|
const part = measure.parts[pi];
|
|
@@ -649,11 +733,19 @@ export const encode = (doc, options = {}) => {
|
|
|
649
733
|
staffMeasures.push([]);
|
|
650
734
|
}
|
|
651
735
|
// Encode voice content
|
|
652
|
-
|
|
736
|
+
let voiceContent = encodeVoice(voice, {
|
|
653
737
|
key: currentKey,
|
|
654
738
|
timeSig: currentTimeSig,
|
|
655
739
|
isFirst: mi === 0
|
|
656
740
|
}, vi);
|
|
741
|
+
// For non-partial measures, pad incomplete voices to fill the full
|
|
742
|
+
// measure duration. lotus doesn't auto-advance on barlines, so
|
|
743
|
+
// under-full voices cause measure boundary miscounting.
|
|
744
|
+
if (!measurePartialDurs[mi] && currentTimeSig) {
|
|
745
|
+
const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
|
|
746
|
+
const voiceTicks = voiceDurationTicks(voice);
|
|
747
|
+
voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
|
|
748
|
+
}
|
|
657
749
|
staffMeasures[mi].push(voiceContent);
|
|
658
750
|
}
|
|
659
751
|
}
|
|
@@ -666,10 +758,28 @@ export const encode = (doc, options = {}) => {
|
|
|
666
758
|
// Build voice lines
|
|
667
759
|
const voiceLines = [];
|
|
668
760
|
for (let vi = 0; vi < maxVoices; vi++) {
|
|
761
|
+
let prevTimeSigStr;
|
|
669
762
|
const measureContents = measures.map((m, mi) => {
|
|
670
|
-
//
|
|
671
|
-
const
|
|
672
|
-
const
|
|
763
|
+
// For partial (pickup) measures, use a partial-duration spacer
|
|
764
|
+
const partialDur = measurePartialDurs[mi];
|
|
765
|
+
const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
|
|
766
|
+
let content = m[vi] || spacer;
|
|
767
|
+
// Inject \time if the content lacks it and the time sig changed.
|
|
768
|
+
// lotus processes each voice independently — without \time it
|
|
769
|
+
// defaults to 4/4, miscounting measure boundaries for other meters.
|
|
770
|
+
const ts = measureTimeSigs[mi];
|
|
771
|
+
if (ts) {
|
|
772
|
+
const tsStr = `${ts.numerator}/${ts.denominator}`;
|
|
773
|
+
if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
|
|
774
|
+
content = `${encodeTimeSig(ts)} ${content}`;
|
|
775
|
+
}
|
|
776
|
+
prevTimeSigStr = tsStr;
|
|
777
|
+
}
|
|
778
|
+
// For partial measures, prepend \partial DUR before all other commands
|
|
779
|
+
// so the lotus interpreter correctly tracks the pickup measure boundary.
|
|
780
|
+
if (partialDur && !content.includes('\\partial')) {
|
|
781
|
+
content = `\\partial ${partialDur} ${content}`;
|
|
782
|
+
}
|
|
673
783
|
// Wrap each measure in its own \relative c' to reset pitch context
|
|
674
784
|
return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
|
|
675
785
|
});
|
|
@@ -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, };
|
|
@@ -301,6 +301,10 @@ const serializeContextChange = (event) => {
|
|
|
301
301
|
if (event.time) {
|
|
302
302
|
parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
|
|
303
303
|
}
|
|
304
|
+
// Partial (pickup measure duration check)
|
|
305
|
+
if (event.partial) {
|
|
306
|
+
parts.push('\\partial ' + event.partial.division + '.'.repeat(event.partial.dots || 0));
|
|
307
|
+
}
|
|
304
308
|
// Ottava
|
|
305
309
|
if (event.ottava !== undefined) {
|
|
306
310
|
if (event.ottava === 0) {
|
|
@@ -343,8 +347,11 @@ const serializeTempo = (tempo) => {
|
|
|
343
347
|
const serializeTupletEvent = (event, env) => {
|
|
344
348
|
const parts = [];
|
|
345
349
|
let currentEnv = env;
|
|
346
|
-
// \times numerator/denominator
|
|
347
|
-
|
|
350
|
+
// \tuplet denominator/numerator { ... } for tuplet type, \times numerator/denominator for times type
|
|
351
|
+
const keyword = event.type === 'times'
|
|
352
|
+
? '\\times ' + event.ratio.numerator + '/' + event.ratio.denominator
|
|
353
|
+
: '\\tuplet ' + event.ratio.denominator + '/' + event.ratio.numerator;
|
|
354
|
+
parts.push(keyword + ' {');
|
|
348
355
|
let prevDuration;
|
|
349
356
|
for (const e of event.events) {
|
|
350
357
|
if (e.type === 'note') {
|
|
@@ -359,6 +366,20 @@ const serializeTupletEvent = (event, env) => {
|
|
|
359
366
|
currentEnv = newEnv;
|
|
360
367
|
prevDuration = e.duration;
|
|
361
368
|
}
|
|
369
|
+
else if (e.type === 'context') {
|
|
370
|
+
const ctx = e;
|
|
371
|
+
if (ctx.staff != null) {
|
|
372
|
+
parts.push(' \\staff "' + ctx.staff + '"');
|
|
373
|
+
}
|
|
374
|
+
else if (ctx.stemDirection != null) {
|
|
375
|
+
if (ctx.stemDirection === StemDirection.up)
|
|
376
|
+
parts.push(' \\stemUp');
|
|
377
|
+
else if (ctx.stemDirection === StemDirection.down)
|
|
378
|
+
parts.push(' \\stemDown');
|
|
379
|
+
else if (ctx.stemDirection === StemDirection.auto)
|
|
380
|
+
parts.push(' \\stemNeutral');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
362
383
|
}
|
|
363
384
|
parts.push(' }');
|
|
364
385
|
return { str: parts.join(''), newEnv: currentEnv };
|
|
@@ -428,6 +449,7 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
428
449
|
case 'context':
|
|
429
450
|
return { str: serializeContextChange(event), newEnv: env };
|
|
430
451
|
case 'tuplet':
|
|
452
|
+
case 'times':
|
|
431
453
|
return serializeTupletEvent(event, env);
|
|
432
454
|
case 'tremolo':
|
|
433
455
|
return serializeTremoloEvent(event, env);
|
|
@@ -468,7 +490,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
468
490
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
469
491
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
470
492
|
// context{staff} events before this index are skipped in the main loop.
|
|
471
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
|
|
493
|
+
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
472
494
|
let effectiveInitialStaff = voice.staff;
|
|
473
495
|
let leadStaffScanEnd = 0;
|
|
474
496
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -607,6 +629,13 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
607
629
|
else if (event.type === 'rest') {
|
|
608
630
|
prevDuration = event.duration;
|
|
609
631
|
}
|
|
632
|
+
else if (event.type === 'tuplet' || event.type === 'times') {
|
|
633
|
+
// After a tuplet/times block the LilyPond parser's "current duration" is the
|
|
634
|
+
// last note duration inside the tuplet, not the duration before the tuplet.
|
|
635
|
+
// Reset prevDuration so the first note after the block always emits its
|
|
636
|
+
// duration explicitly, avoiding wrong inheritance from inside the tuplet.
|
|
637
|
+
prevDuration = undefined;
|
|
638
|
+
}
|
|
610
639
|
else if (event.type === 'context' && event.clef && emittedClefs) {
|
|
611
640
|
const ctx = event;
|
|
612
641
|
emittedClefs[ctx.staff || activeStaff] = ctx.clef;
|
|
@@ -667,7 +696,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
667
696
|
}
|
|
668
697
|
staff = newStaff;
|
|
669
698
|
}
|
|
670
|
-
parts.push(partStrs.join('
|
|
699
|
+
parts.push(partStrs.join(' \\\\\\\n'));
|
|
671
700
|
}
|
|
672
701
|
return { str: parts.join(' '), newStaff: staff };
|
|
673
702
|
};
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -181,6 +181,7 @@ export interface ContextChange {
|
|
|
181
181
|
type: 'context';
|
|
182
182
|
key?: KeySignature;
|
|
183
183
|
time?: Fraction;
|
|
184
|
+
partial?: Duration;
|
|
184
185
|
clef?: Clef;
|
|
185
186
|
ottava?: number;
|
|
186
187
|
stemDirection?: StemDirection;
|
|
@@ -197,7 +198,12 @@ export interface TremoloEvent {
|
|
|
197
198
|
export interface TupletEvent {
|
|
198
199
|
type: 'tuplet';
|
|
199
200
|
ratio: Fraction;
|
|
200
|
-
events: (NoteEvent | RestEvent)[];
|
|
201
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
202
|
+
}
|
|
203
|
+
export interface TimesEvent {
|
|
204
|
+
type: 'times';
|
|
205
|
+
ratio: Fraction;
|
|
206
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
201
207
|
}
|
|
202
208
|
export interface PitchResetEvent {
|
|
203
209
|
type: 'pitchReset';
|
|
@@ -215,7 +221,7 @@ export interface MarkupEvent {
|
|
|
215
221
|
content: string;
|
|
216
222
|
placement?: Placement;
|
|
217
223
|
}
|
|
218
|
-
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
224
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
219
225
|
export interface Voice {
|
|
220
226
|
staff: number;
|
|
221
227
|
events: Event[];
|