@k-l-lambda/lilylet 0.1.59 → 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 +194 -33
- 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 +80 -10
- 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 +174 -36
- 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 +73 -11
- 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
|
}
|
|
@@ -793,6 +909,49 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
793
909
|
},
|
|
794
910
|
});
|
|
795
911
|
context.execute(track.music);
|
|
912
|
+
// Post-process: carry staff state across measure boundaries for this voice.
|
|
913
|
+
// If measure N ends on staff 2 (via \change Staff), measure N+1 must begin
|
|
914
|
+
// with context { staff: 2 } so notes at the start of that measure serialize
|
|
915
|
+
// on the correct staff.
|
|
916
|
+
{
|
|
917
|
+
const measureIndices = Array.from(measureMap.keys()).sort((a, b) => a - b);
|
|
918
|
+
let carryStaff = trackStaff;
|
|
919
|
+
for (const mi of measureIndices) {
|
|
920
|
+
const voice = measureMap.get(mi)?.voices[vi];
|
|
921
|
+
if (!voice)
|
|
922
|
+
continue;
|
|
923
|
+
// Prepend carry-over event if previous measure ended on a different staff,
|
|
924
|
+
// but skip if the voice's first explicit staff event already resets to
|
|
925
|
+
// trackStaff — the carry-over would be immediately cancelled (no-op).
|
|
926
|
+
if (carryStaff !== trackStaff) {
|
|
927
|
+
// Suppress carry-over if the first staff event resets to trackStaff
|
|
928
|
+
// AND no musical events (notes/rests/etc.) precede it — meaning the
|
|
929
|
+
// carry-over would be immediately cancelled with no effect.
|
|
930
|
+
const firstStaffCtxIdx = voice.events.findIndex(e => e.type === 'context' && e.staff != null);
|
|
931
|
+
const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
932
|
+
const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
|
|
933
|
+
voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
|
|
934
|
+
const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
|
|
935
|
+
voice.events[firstStaffCtxIdx].staff === trackStaff &&
|
|
936
|
+
!hasMusicBeforeFirstStaff;
|
|
937
|
+
if (!immediatelyCancelled) {
|
|
938
|
+
voice.events.unshift({ type: 'context', staff: carryStaff });
|
|
939
|
+
}
|
|
940
|
+
}
|
|
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
|
+
}
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
scanStaff(voice.events);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
796
955
|
});
|
|
797
956
|
// Filter out empty voices and convert to array, sorted by measure number
|
|
798
957
|
const measures = Array.from(measureMap.entries())
|
|
@@ -812,6 +971,8 @@ const hasRealContent = (events) => {
|
|
|
812
971
|
return true;
|
|
813
972
|
if (e.type === 'tuplet')
|
|
814
973
|
return true;
|
|
974
|
+
if (e.type === 'times')
|
|
975
|
+
return true;
|
|
815
976
|
if (e.type === 'tremolo')
|
|
816
977
|
return true;
|
|
817
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, };
|