@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
|
@@ -51,22 +51,46 @@
|
|
|
51
51
|
mode,
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
const voice = (staff, events) =>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const voice = (staff, events) => {
|
|
55
|
+
// Use the first \staff "N" context event that appears before any musical event
|
|
56
|
+
// (notes/rests/tuplets) as the authoritative voice.staff.
|
|
57
|
+
// Skip pitchReset events (from NEWLINE tokens at voice-line boundaries).
|
|
58
|
+
// This ensures \staff "1" \times ... \staff "2" ... gives voice.staff=1,
|
|
59
|
+
// not 2 (the final currentStaff value), while \staff "2" c1 gives voice.staff=2.
|
|
60
|
+
let leadingStaff = null;
|
|
61
|
+
for (const e of events) {
|
|
62
|
+
if (!e) continue;
|
|
63
|
+
if (e.type === 'pitchReset') continue;
|
|
64
|
+
if (e.type === 'context') {
|
|
65
|
+
if (e.staff != null) { leadingStaff = e.staff; break; }
|
|
66
|
+
continue; // skip non-staff context events (key, clef, time, etc.)
|
|
67
|
+
}
|
|
68
|
+
break; // first musical/structural event — stop
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
staff: leadingStaff != null ? leadingStaff : 1,
|
|
72
|
+
events,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
58
75
|
|
|
59
76
|
const part = (voices, name) => ({
|
|
60
77
|
name: name || undefined,
|
|
61
78
|
voices,
|
|
62
79
|
});
|
|
63
80
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
82
|
+
const hasMusicalContent = (parts) =>
|
|
83
|
+
parts.some(p => p.voices.some(v => v.events.some(e => e && MUSICAL.has(e.type))));
|
|
84
|
+
|
|
85
|
+
const measure = (parts, key, timeSig, partial) => {
|
|
86
|
+
if (!hasMusicalContent(parts)) return null;
|
|
87
|
+
return {
|
|
88
|
+
key: key || undefined,
|
|
89
|
+
timeSig: timeSig || undefined,
|
|
90
|
+
parts,
|
|
91
|
+
partial: partial || undefined,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
70
94
|
|
|
71
95
|
const tupletEvent = (ratio, events) => ({
|
|
72
96
|
type: 'tuplet',
|
|
@@ -74,6 +98,12 @@
|
|
|
74
98
|
events,
|
|
75
99
|
});
|
|
76
100
|
|
|
101
|
+
const timesEvent = (ratio, events) => ({
|
|
102
|
+
type: 'times',
|
|
103
|
+
ratio,
|
|
104
|
+
events,
|
|
105
|
+
});
|
|
106
|
+
|
|
77
107
|
const tremoloEvent = (pitchA, pitchB, count, division) => ({
|
|
78
108
|
type: 'tremolo',
|
|
79
109
|
pitchA,
|
|
@@ -131,6 +161,9 @@
|
|
|
131
161
|
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
132
162
|
let currentOttava = 0; // Current ottava level, resets on newline
|
|
133
163
|
|
|
164
|
+
// \partial warnings accumulated during parse; reset before each parse
|
|
165
|
+
let parseWarnings = [];
|
|
166
|
+
|
|
134
167
|
// Reset parser state - call before each parse
|
|
135
168
|
const resetParserState = () => {
|
|
136
169
|
currentStaff = 1;
|
|
@@ -139,10 +172,40 @@
|
|
|
139
172
|
currentDuration = { division: 4, dots: 0 };
|
|
140
173
|
numericTimeSignature = false;
|
|
141
174
|
currentOttava = 0;
|
|
175
|
+
parseWarnings = [];
|
|
142
176
|
};
|
|
143
177
|
|
|
144
|
-
// Export reset function
|
|
178
|
+
// Export reset function and warning accessors
|
|
145
179
|
parser.resetState = resetParserState;
|
|
180
|
+
parser.getWarnings = () => parseWarnings;
|
|
181
|
+
|
|
182
|
+
// Duration ticks helper for \partial validation (TPQN=480)
|
|
183
|
+
const durationTicks = (dur) => {
|
|
184
|
+
let t = 1920 / dur.division; // 1920 = whole note ticks
|
|
185
|
+
let dot = t / 2;
|
|
186
|
+
for (let i = 0; i < (dur.dots || 0); i++) { t += dot; dot /= 2; }
|
|
187
|
+
return Math.round(t);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Sum ticks for an event list (notes/rests only, skip graces)
|
|
191
|
+
const voiceEventTicks = (events) => {
|
|
192
|
+
let total = 0;
|
|
193
|
+
for (const e of events) {
|
|
194
|
+
if (!e) continue;
|
|
195
|
+
if (e.type === 'note' || e.type === 'rest') {
|
|
196
|
+
if (e.grace) continue;
|
|
197
|
+
total += durationTicks(e.duration);
|
|
198
|
+
} else if (e.type === 'tuplet' || e.type === 'times') {
|
|
199
|
+
const factor = e.ratio.numerator / e.ratio.denominator;
|
|
200
|
+
for (const inner of e.events || []) {
|
|
201
|
+
if (inner && (inner.type === 'note' || inner.type === 'rest') && !inner.grace) {
|
|
202
|
+
total += Math.round(durationTicks(inner.duration) * factor);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return total;
|
|
208
|
+
};
|
|
146
209
|
%}
|
|
147
210
|
|
|
148
211
|
|
|
@@ -173,12 +236,14 @@
|
|
|
173
236
|
"\\clef" return 'CMD_CLEF'
|
|
174
237
|
"\\key" return 'CMD_KEY'
|
|
175
238
|
"\\time" return 'CMD_TIME'
|
|
239
|
+
"\\partial" return 'CMD_PARTIAL'
|
|
176
240
|
"\\numericTimeSignature" return 'CMD_NUMERIC_TIME_SIG'
|
|
177
241
|
"\\defaultTimeSignature" return 'CMD_DEFAULT_TIME_SIG'
|
|
178
242
|
"\\tempo" return 'CMD_TEMPO'
|
|
179
243
|
"\\staff" return 'CMD_STAFF'
|
|
180
244
|
"\\grace" return 'CMD_GRACE'
|
|
181
245
|
"\\times" return 'CMD_TIMES'
|
|
246
|
+
"\\tuplet" return 'CMD_TUPLET'
|
|
182
247
|
"\\repeat" return 'CMD_REPEAT'
|
|
183
248
|
"\\ottava" return 'CMD_OTTAVA'
|
|
184
249
|
"\\stemUp" return 'CMD_STEMUP'
|
|
@@ -313,13 +378,41 @@ header
|
|
|
313
378
|
;
|
|
314
379
|
|
|
315
380
|
measures
|
|
316
|
-
: measure_content { $$ = [$1]; }
|
|
317
|
-
| measures '|' measure_content { $$ = $1.concat([$3]); }
|
|
381
|
+
: measure_content { $$ = $1 ? [$1] : []; }
|
|
382
|
+
| measures '|' measure_content { $$ = $3 ? $1.concat([$3]) : $1; }
|
|
318
383
|
| measures '|' { $$ = $1; }
|
|
319
384
|
;
|
|
320
385
|
|
|
321
386
|
measure_content
|
|
322
|
-
: parts
|
|
387
|
+
: parts %{
|
|
388
|
+
// Check \partial declarations: warn if declared duration ≠ actual voice ticks
|
|
389
|
+
let partialDur = null;
|
|
390
|
+
outer: for (const p of $1) {
|
|
391
|
+
for (const v of p.voices) {
|
|
392
|
+
for (const e of v.events) {
|
|
393
|
+
if (e && e.type === 'context' && e.partial) {
|
|
394
|
+
partialDur = e.partial;
|
|
395
|
+
break outer;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (partialDur) {
|
|
401
|
+
const declared = durationTicks(partialDur);
|
|
402
|
+
for (const p of $1) {
|
|
403
|
+
for (const v of p.voices) {
|
|
404
|
+
const actual = voiceEventTicks(v.events);
|
|
405
|
+
if (actual > 0 && actual !== declared) {
|
|
406
|
+
const durStr = partialDur.division + '.'.repeat(partialDur.dots || 0);
|
|
407
|
+
const msg = `\\partial ${durStr}: declared ${declared} ticks but voice has ${actual} ticks`;
|
|
408
|
+
console.warn('[lilylet] ' + msg);
|
|
409
|
+
parseWarnings.push({ type: 'partial-mismatch', message: msg, declared, actual });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
$$ = measure($1, currentKey, currentTimeSig, partialDur ? true : undefined);
|
|
415
|
+
%}
|
|
323
416
|
;
|
|
324
417
|
|
|
325
418
|
parts
|
|
@@ -429,6 +522,7 @@ context_event
|
|
|
429
522
|
: clef_cmd -> contextChange({ clef: $1 })
|
|
430
523
|
| key_cmd -> contextChange({ key: $1 })
|
|
431
524
|
| time_cmd -> contextChange({ time: $1 })
|
|
525
|
+
| partial_cmd -> contextChange({ partial: $1 })
|
|
432
526
|
| tempo_cmd -> contextChange({ tempo: $1 })
|
|
433
527
|
| staff_cmd -> contextChange({ staff: $1 })
|
|
434
528
|
| ottava_cmd -> contextChange({ ottava: $1 })
|
|
@@ -480,6 +574,10 @@ default_time_sig_cmd
|
|
|
480
574
|
: CMD_DEFAULT_TIME_SIG %{ numericTimeSignature = false; $$ = null; %}
|
|
481
575
|
;
|
|
482
576
|
|
|
577
|
+
partial_cmd
|
|
578
|
+
: CMD_PARTIAL duration -> $2
|
|
579
|
+
;
|
|
580
|
+
|
|
483
581
|
tempo_cmd
|
|
484
582
|
: CMD_TEMPO STRING duration '=' NUMBER -> ({ text: $2.slice(1, -1), beat: $3, bpm: Number($5) })
|
|
485
583
|
| CMD_TEMPO STRING -> ({ text: $2.slice(1, -1) })
|
|
@@ -509,7 +607,8 @@ grace_event
|
|
|
509
607
|
;
|
|
510
608
|
|
|
511
609
|
tuplet_event
|
|
512
|
-
: CMD_TIMES NUMBER '/' NUMBER '{' voice_events '}' ->
|
|
610
|
+
: CMD_TIMES NUMBER '/' NUMBER '{' voice_events '}' -> timesEvent(fraction(Number($2), Number($4)), $6.filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'context'))
|
|
611
|
+
| CMD_TUPLET NUMBER '/' NUMBER '{' voice_events '}' -> tupletEvent(fraction(Number($4), Number($2)), $6.filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'context'))
|
|
513
612
|
;
|
|
514
613
|
|
|
515
614
|
tremolo_pitches
|
|
@@ -489,6 +489,42 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
489
489
|
|
|
490
490
|
const interpreter = lilyDocument.interpret();
|
|
491
491
|
|
|
492
|
+
// Pre-compute partIndex for each track using staff-number-sequence heuristic.
|
|
493
|
+
// When staff numbers reset (decrease), a new part starts — this handles multi-PianoStaff
|
|
494
|
+
// scores where both pianos reuse the same staff names ("1" and "2").
|
|
495
|
+
// Scores that embed partIndex in the staff name ("1_1", "1_2", "2_1") are handled
|
|
496
|
+
// by parseStaffName; this heuristic only kicks in for plain numeric names.
|
|
497
|
+
let _seqPart = 1;
|
|
498
|
+
let _seqMaxStaff = 0;
|
|
499
|
+
const _trackPartIndices: number[] = interpreter.layoutMusic.musicTracks.map((track: any) => {
|
|
500
|
+
const staffName: string | undefined = track.contextDict?.Staff;
|
|
501
|
+
if (staffName && /^\d+_\d+$/.test(staffName)) {
|
|
502
|
+
// Staff name encodes partIndex explicitly — don't apply heuristic
|
|
503
|
+
return parseInt(staffName.split('_')[0], 10);
|
|
504
|
+
}
|
|
505
|
+
// PianoStaff is present (even as empty string "") when inside a grand-staff group;
|
|
506
|
+
// undefined means standalone instrument → different part from grand-staff tracks.
|
|
507
|
+
const hasPianoStaff = (track.contextDict?.PianoStaff !== undefined);
|
|
508
|
+
const staffNum = parseInt(staffName || '1', 10) || 1;
|
|
509
|
+
|
|
510
|
+
if (hasPianoStaff) {
|
|
511
|
+
// Grand-staff: new part if transitioning from standalone or staff resets
|
|
512
|
+
if (_seqMaxStaff > 0 && staffNum < _seqMaxStaff) {
|
|
513
|
+
_seqPart++;
|
|
514
|
+
_seqMaxStaff = 0;
|
|
515
|
+
} else if (_seqMaxStaff === -1) {
|
|
516
|
+
// Transitioning from a standalone group
|
|
517
|
+
_seqPart++;
|
|
518
|
+
_seqMaxStaff = 0;
|
|
519
|
+
}
|
|
520
|
+
_seqMaxStaff = Math.max(_seqMaxStaff, staffNum);
|
|
521
|
+
} else {
|
|
522
|
+
// Standalone: mark with -1 so next grand-staff group increments
|
|
523
|
+
_seqMaxStaff = -1;
|
|
524
|
+
}
|
|
525
|
+
return _seqPart;
|
|
526
|
+
});
|
|
527
|
+
|
|
492
528
|
interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
|
|
493
529
|
const appendStaff = (staffName: string): void => {
|
|
494
530
|
if (!staffNames.includes(staffName)) {
|
|
@@ -498,15 +534,20 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
498
534
|
|
|
499
535
|
// Parse staff name to extract partIndex and staff number
|
|
500
536
|
// Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
|
|
501
|
-
// Falls back to
|
|
537
|
+
// Falls back to positional ordering for non-numeric names (e.g., "upper"→1, "lower"→2)
|
|
502
538
|
const parseStaffName = (name: string): { partIndex: number; staffNum: number } => {
|
|
503
539
|
const match = name.match(/^(\d+)_(\d+)$/);
|
|
504
540
|
if (match) {
|
|
505
541
|
return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
|
|
506
542
|
}
|
|
507
|
-
// Fallback: single part, staff number from name or 1
|
|
508
543
|
const num = parseInt(name, 10);
|
|
509
|
-
|
|
544
|
+
if (!isNaN(num)) {
|
|
545
|
+
return { partIndex: 1, staffNum: num };
|
|
546
|
+
}
|
|
547
|
+
// Non-numeric name: assign staffNum by order of first appearance in staffNames
|
|
548
|
+
appendStaff(name);
|
|
549
|
+
const idx = staffNames.indexOf(name);
|
|
550
|
+
return { partIndex: 1, staffNum: idx + 1 };
|
|
510
551
|
};
|
|
511
552
|
|
|
512
553
|
// Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
|
|
@@ -515,10 +556,14 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
515
556
|
if (initialStaffName) {
|
|
516
557
|
appendStaff(initialStaffName);
|
|
517
558
|
}
|
|
518
|
-
|
|
559
|
+
// Empty string staff name ("") means unnamed staff — treat as staff 1 within its part
|
|
560
|
+
const parsedStaff = (initialStaffName != null && initialStaffName !== '')
|
|
561
|
+
? parseStaffName(initialStaffName)
|
|
562
|
+
: { partIndex: 1, staffNum: 1 };
|
|
519
563
|
// Use these as fixed values for this track - don't update from context.staffName
|
|
520
564
|
const trackStaff = parsedStaff.staffNum;
|
|
521
|
-
|
|
565
|
+
// Use sequence-based partIndex (detects multi-PianoStaff via staff number reset)
|
|
566
|
+
const trackPartIndex = _trackPartIndices[vi] ?? parsedStaff.partIndex;
|
|
522
567
|
|
|
523
568
|
// Track emitted context events across measures for this voice
|
|
524
569
|
let lastKey: number | undefined = undefined; // Track value changes (key fifths)
|
|
@@ -526,6 +571,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
526
571
|
let lastClef: Clef | undefined = undefined; // Track value changes
|
|
527
572
|
let lastOttava: number | undefined = undefined; // Track value changes
|
|
528
573
|
let lastStemDirection: string | undefined = undefined; // Track value changes
|
|
574
|
+
let partialEmitted = false; // Emit \partial context once per track
|
|
529
575
|
|
|
530
576
|
const context = new lilyParser.TrackContext(undefined, {
|
|
531
577
|
listener: (term: lilyParser.BaseTerm, context: lilyParser.TrackContext) => {
|
|
@@ -553,11 +599,12 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
553
599
|
}
|
|
554
600
|
const voice = measure.voices[vi];
|
|
555
601
|
|
|
556
|
-
// Update key/time from context on music events
|
|
602
|
+
// Update key/time/partial from context on music events
|
|
557
603
|
if (term instanceof lilyParser.MusicEvent ||
|
|
558
604
|
term instanceof lilyParser.LilyTerms.StemDirection ||
|
|
559
605
|
term instanceof lilyParser.LilyTerms.OctaveShift ||
|
|
560
|
-
term instanceof lilyParser.LilyTerms.Change
|
|
606
|
+
term instanceof lilyParser.LilyTerms.Change ||
|
|
607
|
+
term instanceof lilyParser.LilyTerms.Partial) {
|
|
561
608
|
|
|
562
609
|
if (context.key && measure.key === null) {
|
|
563
610
|
measure.key = context.key.key;
|
|
@@ -573,6 +620,21 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
573
620
|
}
|
|
574
621
|
}
|
|
575
622
|
|
|
623
|
+
// Emit \partial context event when the Partial term fires (before partialDuration is cleared)
|
|
624
|
+
if (term instanceof lilyParser.LilyTerms.Partial && context.partialDuration && !partialEmitted) {
|
|
625
|
+
const mag = (context.partialDuration as any).magnitude;
|
|
626
|
+
const WHOLE = 1920;
|
|
627
|
+
let division = 1, dots = 0;
|
|
628
|
+
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
629
|
+
const base = WHOLE / div;
|
|
630
|
+
if (Math.abs(mag - base) < 1) { division = div; dots = 0; break; }
|
|
631
|
+
if (Math.abs(mag - Math.round(base * 1.5)) < 1) { division = div; dots = 1; break; }
|
|
632
|
+
if (Math.abs(mag - Math.round(base * 1.75)) < 1) { division = div; dots = 2; break; }
|
|
633
|
+
}
|
|
634
|
+
voice.events.push({ type: 'context', partial: { division, dots } });
|
|
635
|
+
partialEmitted = true;
|
|
636
|
+
}
|
|
637
|
+
|
|
576
638
|
// Handle music events
|
|
577
639
|
if (term instanceof lilyParser.MusicEvent) {
|
|
578
640
|
// Staff is fixed per track (from track definition)
|
|
@@ -706,14 +768,18 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
706
768
|
}
|
|
707
769
|
// Process Rest
|
|
708
770
|
else if (term instanceof lilyParser.LilyTerms.Rest) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
771
|
+
// Ignore spacer rests inside grace contexts (e.g. \acciaccatura s8,
|
|
772
|
+
// \grace s4 — these are notation-only placeholders with no musical content)
|
|
773
|
+
if (!(term.isSpacer && context.inGrace)) {
|
|
774
|
+
const restEvent: RestEvent = {
|
|
775
|
+
type: 'rest',
|
|
776
|
+
duration: convertDuration(term.durationValue),
|
|
777
|
+
fullMeasure: (term.name === 'R') || undefined,
|
|
778
|
+
invisible: term.isSpacer || undefined,
|
|
779
|
+
};
|
|
715
780
|
|
|
716
|
-
|
|
781
|
+
voice.events.push(restEvent);
|
|
782
|
+
}
|
|
717
783
|
}
|
|
718
784
|
}
|
|
719
785
|
// Handle standalone stem direction (emit when value changes)
|
|
@@ -834,46 +900,71 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
834
900
|
// Handle tuplet
|
|
835
901
|
// Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
|
|
836
902
|
// remove the already-added notes and wrap them in a TupletEvent
|
|
837
|
-
else if (termAny.proto === 'Tuplet') {
|
|
838
|
-
const
|
|
839
|
-
const
|
|
903
|
+
else if (termAny.proto === 'Tuplet' || termAny.proto === 'Times') {
|
|
904
|
+
const isTimes = termAny.proto === 'Times';
|
|
905
|
+
const ratioStr = termAny.args?.[0]; // "3/2" for \tuplet, "2/3" for \times
|
|
906
|
+
// \tuplet supports an optional base-duration arg: \tuplet 3/2 4 { notes }
|
|
907
|
+
// making args = [ratio, baseDur?, body]. Use the last arg (= music block),
|
|
908
|
+
// matching what lotus Tuplet.music getter does: this.args[this.args.length-1]
|
|
909
|
+
const body = termAny.args?.[termAny.args.length - 1]?.body || [];
|
|
840
910
|
|
|
841
911
|
if (ratioStr && body.length > 0) {
|
|
842
|
-
// Parse ratio string
|
|
843
912
|
const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
|
|
844
913
|
if (ratioMatch) {
|
|
845
914
|
const [, num, denom] = ratioMatch;
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
915
|
+
// \tuplet 3/2: divider = 3/2, lilylet ratio = 2/3 → swap
|
|
916
|
+
// \times 2/3: factor = 2/3, lilylet ratio = 2/3 → no swap
|
|
917
|
+
const ratio: Fraction = isTimes
|
|
918
|
+
? { numerator: parseInt(num, 10), denominator: parseInt(denom, 10) }
|
|
919
|
+
: { numerator: parseInt(denom, 10), denominator: parseInt(num, 10) };
|
|
920
|
+
|
|
921
|
+
// Count sounding (non-grace) notes/rests in the tuplet body,
|
|
922
|
+
// recursing into AfterGrace.body (main note) but not AfterGrace.grace.
|
|
923
|
+
// Grace / Acciaccatura / Appoggiatura blocks count as 0.
|
|
924
|
+
const countSounding = (items: any[]): number => {
|
|
925
|
+
let n = 0;
|
|
926
|
+
for (const item of items) {
|
|
927
|
+
if (!item) continue;
|
|
928
|
+
switch (item.proto) {
|
|
929
|
+
case 'Chord': case 'Rest': n++; break;
|
|
930
|
+
case 'AfterGrace': {
|
|
931
|
+
// args[0] = main note, args[1] = after-grace notes (0-dur, skip)
|
|
932
|
+
const main = item.args?.[0];
|
|
933
|
+
if (main?.proto === 'Chord' || main?.proto === 'Rest') n++;
|
|
934
|
+
else if (main?.body) n += countSounding(main.body);
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
case 'Grace': case 'Acciaccatura': case 'Appoggiatura': break;
|
|
938
|
+
default: if (item.body) n += countSounding(item.body); break;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return n;
|
|
849
942
|
};
|
|
943
|
+
const noteCount = countSounding(body);
|
|
850
944
|
|
|
851
|
-
// Count how many note/rest events are in the tuplet body
|
|
852
|
-
const noteCount = body.filter((item: any) =>
|
|
853
|
-
item.proto === 'Chord' || item.proto === 'Rest'
|
|
854
|
-
).length;
|
|
855
|
-
|
|
856
|
-
// Remove the last noteCount note/rest events from voice.events
|
|
857
|
-
// (they were already added by the Chord/Rest handlers)
|
|
858
945
|
const tupletEvents: (NoteEvent | RestEvent)[] = [];
|
|
859
946
|
let removed = 0;
|
|
947
|
+
// Pop notes/rests from voice.events, skipping context events.
|
|
948
|
+
// Grace notes are moved inside the tuplet but do NOT consume a
|
|
949
|
+
// sounding-note slot (they have 0 duration in the time signature).
|
|
860
950
|
while (removed < noteCount && voice.events.length > 0) {
|
|
861
951
|
const lastEvent = voice.events[voice.events.length - 1];
|
|
862
952
|
if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
|
|
863
953
|
tupletEvents.unshift(voice.events.pop()! as NoteEvent | RestEvent);
|
|
864
|
-
removed++;
|
|
954
|
+
if (!(lastEvent as NoteEvent).grace) removed++;
|
|
955
|
+
} else if (lastEvent.type === 'context' || lastEvent.type === 'pitchReset') {
|
|
956
|
+
// Context event between tuplet notes — move it inside tuplet too
|
|
957
|
+
tupletEvents.unshift(voice.events.pop()! as any);
|
|
865
958
|
} else {
|
|
866
|
-
break;
|
|
959
|
+
break;
|
|
867
960
|
}
|
|
868
961
|
}
|
|
869
962
|
|
|
870
963
|
if (tupletEvents.length > 0) {
|
|
871
|
-
|
|
872
|
-
type: '
|
|
873
|
-
ratio,
|
|
874
|
-
|
|
875
|
-
};
|
|
876
|
-
voice.events.push(tupletEvent);
|
|
964
|
+
voice.events.push(isTimes
|
|
965
|
+
? { type: 'times', ratio, events: tupletEvents } as any
|
|
966
|
+
: { type: 'tuplet', ratio, events: tupletEvents } as TupletEvent
|
|
967
|
+
);
|
|
877
968
|
}
|
|
878
969
|
}
|
|
879
970
|
}
|
|
@@ -946,7 +1037,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
946
1037
|
const firstStaffCtxIdx = voice.events.findIndex(
|
|
947
1038
|
e => e.type === 'context' && (e as any).staff != null
|
|
948
1039
|
);
|
|
949
|
-
const musicalTypes = new Set(['note', 'rest', 'tuplet', 'tremolo']);
|
|
1040
|
+
const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
950
1041
|
const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
|
|
951
1042
|
voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
|
|
952
1043
|
const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
|
|
@@ -957,12 +1048,17 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
957
1048
|
}
|
|
958
1049
|
}
|
|
959
1050
|
|
|
960
|
-
// Update carryStaff from this measure's events
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1051
|
+
// Update carryStaff from this measure's events (including inside tuplets)
|
|
1052
|
+
const scanStaff = (events: any[]): void => {
|
|
1053
|
+
for (const e of events) {
|
|
1054
|
+
if (e.type === 'context' && (e as any).staff) {
|
|
1055
|
+
carryStaff = (e as any).staff;
|
|
1056
|
+
} else if ((e.type === 'tuplet' || e.type === 'times') && e.events) {
|
|
1057
|
+
scanStaff(e.events);
|
|
1058
|
+
}
|
|
964
1059
|
}
|
|
965
|
-
}
|
|
1060
|
+
};
|
|
1061
|
+
scanStaff(voice.events);
|
|
966
1062
|
}
|
|
967
1063
|
}
|
|
968
1064
|
});
|
|
@@ -985,6 +1081,7 @@ const hasRealContent = (events: Event[]): boolean => {
|
|
|
985
1081
|
if (e.type === 'note') return true;
|
|
986
1082
|
if (e.type === 'rest' && !(e as RestEvent).invisible) return true;
|
|
987
1083
|
if (e.type === 'tuplet') return true;
|
|
1084
|
+
if (e.type === 'times') return true;
|
|
988
1085
|
if (e.type === 'tremolo') return true;
|
|
989
1086
|
return false;
|
|
990
1087
|
});
|