@k-l-lambda/lilylet 0.1.55 → 0.1.57
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 +140 -134
- package/lib/lilylet/lilypondDecoder.js +7 -11
- package/lib/lilylet/lilypondEncoder.js +32 -11
- package/lib/lilylet/meiEncoder.js +26 -5
- package/lib/lilylet/types.d.ts +1 -0
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +140 -134
- package/source/lilylet/lilylet.jison +3 -2
- package/source/lilylet/lilypondDecoder.ts +11 -18
- package/source/lilylet/lilypondEncoder.ts +29 -11
- package/source/lilylet/meiEncoder.ts +22 -5
- package/source/lilylet/types.ts +1 -0
|
@@ -396,7 +396,9 @@ pitches
|
|
|
396
396
|
;
|
|
397
397
|
|
|
398
398
|
pitch
|
|
399
|
-
: PITCH octave
|
|
399
|
+
: PITCH octave '!' -> { ...parsePitch($1, $2), courtesy: true }
|
|
400
|
+
| PITCH '!' -> { ...parsePitch($1, 0), courtesy: true }
|
|
401
|
+
| PITCH octave -> parsePitch($1, $2)
|
|
400
402
|
| PITCH -> parsePitch($1, 0)
|
|
401
403
|
;
|
|
402
404
|
|
|
@@ -564,7 +566,6 @@ articulation_mark
|
|
|
564
566
|
| '>' -> articulation('accent')
|
|
565
567
|
| '.' -> articulation('staccato')
|
|
566
568
|
| '-' -> articulation('tenuto')
|
|
567
|
-
| '!' -> articulation('staccatissimo')
|
|
568
569
|
| '^' -> articulation('marcato')
|
|
569
570
|
| '_' -> articulation('portato')
|
|
570
571
|
;
|
|
@@ -711,15 +711,6 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
711
711
|
invisible: term.isSpacer || undefined,
|
|
712
712
|
};
|
|
713
713
|
|
|
714
|
-
// Positioned rest
|
|
715
|
-
if (!term.isSpacer && context.pitch) {
|
|
716
|
-
restEvent.pitch = convertPitch(
|
|
717
|
-
context.pitch.phonetStep,
|
|
718
|
-
0,
|
|
719
|
-
context.pitch.octave
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
714
|
voice.events.push(restEvent);
|
|
724
715
|
}
|
|
725
716
|
}
|
|
@@ -1145,8 +1136,8 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
|
|
|
1145
1136
|
// Filter out voices that only contain spacer rests and context changes
|
|
1146
1137
|
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
1147
1138
|
|
|
1148
|
-
// Group voices by partIndex, then
|
|
1149
|
-
const partMap = new Map<number, Map<number, Event[]>>();
|
|
1139
|
+
// Group voices by partIndex, then collect voice arrays per staff
|
|
1140
|
+
const partMap = new Map<number, Map<number, Event[][]>>();
|
|
1150
1141
|
for (const v of filteredVoices) {
|
|
1151
1142
|
const pi = v.partIndex || 1;
|
|
1152
1143
|
if (!partMap.has(pi)) {
|
|
@@ -1154,24 +1145,26 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
|
|
|
1154
1145
|
}
|
|
1155
1146
|
const staffMap = partMap.get(pi)!;
|
|
1156
1147
|
|
|
1157
|
-
//
|
|
1148
|
+
// Preserve each voice as a separate array
|
|
1158
1149
|
if (!staffMap.has(v.staff)) {
|
|
1159
1150
|
staffMap.set(v.staff, []);
|
|
1160
1151
|
}
|
|
1161
|
-
staffMap.get(v.staff)!.push(
|
|
1152
|
+
staffMap.get(v.staff)!.push(v.events);
|
|
1162
1153
|
}
|
|
1163
1154
|
|
|
1164
1155
|
// Convert to parts array (sorted by part index, then by staff)
|
|
1165
|
-
// Apply deduplication to
|
|
1156
|
+
// Apply deduplication to each voice's events
|
|
1166
1157
|
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1167
1158
|
const parts = partIndices.map(pi => {
|
|
1168
1159
|
const staffMap = partMap.get(pi)!;
|
|
1169
1160
|
const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
|
|
1170
1161
|
return {
|
|
1171
|
-
voices: staffNums.
|
|
1172
|
-
staff
|
|
1173
|
-
|
|
1174
|
-
|
|
1162
|
+
voices: staffNums.flatMap(staff =>
|
|
1163
|
+
staffMap.get(staff)!.map(events => ({
|
|
1164
|
+
staff,
|
|
1165
|
+
events: dedupeContextEvents(events),
|
|
1166
|
+
}))
|
|
1167
|
+
),
|
|
1175
1168
|
};
|
|
1176
1169
|
});
|
|
1177
1170
|
|
|
@@ -394,11 +394,18 @@ const encodeNoteEvent = (event: NoteEvent, env: PitchEnv, lastDuration: Duration
|
|
|
394
394
|
if (event.pitches.length > 1) {
|
|
395
395
|
result += '<';
|
|
396
396
|
const pitchStrs: string[] = [];
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
let firstPitchEnv: PitchEnv | undefined;
|
|
398
|
+
for (let i = 0; i < event.pitches.length; i++) {
|
|
399
|
+
// In LilyPond relative mode, each pitch in a chord is relative
|
|
400
|
+
// to the previous pitch in the chord (cascading).
|
|
401
|
+
// After the chord, env becomes the first pitch.
|
|
402
|
+
const { str, newEnv: ne } = encodePitch(event.pitches[i], newEnv);
|
|
399
403
|
pitchStrs.push(str);
|
|
400
404
|
newEnv = ne;
|
|
405
|
+
if (i === 0) firstPitchEnv = ne;
|
|
401
406
|
}
|
|
407
|
+
// After chord, reference resets to first pitch
|
|
408
|
+
newEnv = firstPitchEnv!;
|
|
402
409
|
result += pitchStrs.join(' ');
|
|
403
410
|
result += '>';
|
|
404
411
|
} else if (event.pitches.length === 1) {
|
|
@@ -455,13 +462,15 @@ const encodeRestEvent = (event: RestEvent, env: PitchEnv, lastDuration: Duration
|
|
|
455
462
|
}
|
|
456
463
|
|
|
457
464
|
// Positioned rest
|
|
465
|
+
let newEnv = env;
|
|
458
466
|
if (event.pitch && !event.fullMeasure && !event.invisible) {
|
|
459
|
-
const { str } = encodePitch(event.pitch, env);
|
|
467
|
+
const { str, newEnv: ne } = encodePitch(event.pitch, env);
|
|
460
468
|
result = str + result.slice(1); // Replace 'r' with pitch
|
|
461
469
|
result += '\\rest';
|
|
470
|
+
newEnv = ne;
|
|
462
471
|
}
|
|
463
472
|
|
|
464
|
-
return { str: result, newEnv
|
|
473
|
+
return { str: result, newEnv, newDuration: event.duration };
|
|
465
474
|
};
|
|
466
475
|
|
|
467
476
|
|
|
@@ -512,8 +521,9 @@ const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Dura
|
|
|
512
521
|
newEnv = ne;
|
|
513
522
|
newDuration = nd;
|
|
514
523
|
} else if (subEvent.type === 'rest') {
|
|
515
|
-
const { str, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
|
|
524
|
+
const { str, newEnv: ne, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
|
|
516
525
|
result += str + ' ';
|
|
526
|
+
newEnv = ne;
|
|
517
527
|
newDuration = nd;
|
|
518
528
|
}
|
|
519
529
|
}
|
|
@@ -535,11 +545,14 @@ const encodeTremoloEvent = (event: TremoloEvent, env: PitchEnv): { str: string;
|
|
|
535
545
|
if (event.pitchA.length > 1) {
|
|
536
546
|
pitchA += '<';
|
|
537
547
|
const pitchStrs: string[] = [];
|
|
538
|
-
|
|
539
|
-
|
|
548
|
+
let firstPitchEnv: PitchEnv | undefined;
|
|
549
|
+
for (let i = 0; i < event.pitchA.length; i++) {
|
|
550
|
+
const { str, newEnv: ne } = encodePitch(event.pitchA[i], newEnv);
|
|
540
551
|
pitchStrs.push(str);
|
|
541
552
|
newEnv = ne;
|
|
553
|
+
if (i === 0) firstPitchEnv = ne;
|
|
542
554
|
}
|
|
555
|
+
newEnv = firstPitchEnv!;
|
|
543
556
|
pitchA += pitchStrs.join(' ');
|
|
544
557
|
pitchA += '>';
|
|
545
558
|
} else if (event.pitchA.length === 1) {
|
|
@@ -553,11 +566,14 @@ const encodeTremoloEvent = (event: TremoloEvent, env: PitchEnv): { str: string;
|
|
|
553
566
|
if (event.pitchB.length > 1) {
|
|
554
567
|
pitchB += '<';
|
|
555
568
|
const pitchStrs: string[] = [];
|
|
556
|
-
|
|
557
|
-
|
|
569
|
+
let firstPitchEnv: PitchEnv | undefined;
|
|
570
|
+
for (let i = 0; i < event.pitchB.length; i++) {
|
|
571
|
+
const { str, newEnv: ne } = encodePitch(event.pitchB[i], newEnv);
|
|
558
572
|
pitchStrs.push(str);
|
|
559
573
|
newEnv = ne;
|
|
574
|
+
if (i === 0) firstPitchEnv = ne;
|
|
560
575
|
}
|
|
576
|
+
newEnv = firstPitchEnv!;
|
|
561
577
|
pitchB += pitchStrs.join(' ');
|
|
562
578
|
pitchB += '>';
|
|
563
579
|
} else if (event.pitchB.length === 1) {
|
|
@@ -624,8 +640,9 @@ const encodeVoice = (
|
|
|
624
640
|
break;
|
|
625
641
|
}
|
|
626
642
|
case 'rest': {
|
|
627
|
-
const { str, newDuration } = encodeRestEvent(event, env, lastDuration);
|
|
643
|
+
const { str, newEnv, newDuration } = encodeRestEvent(event, env, lastDuration);
|
|
628
644
|
result += str + ' ';
|
|
645
|
+
env = newEnv;
|
|
629
646
|
lastDuration = newDuration;
|
|
630
647
|
break;
|
|
631
648
|
}
|
|
@@ -662,7 +679,8 @@ const encodeVoice = (
|
|
|
662
679
|
break;
|
|
663
680
|
}
|
|
664
681
|
case 'pitchReset': {
|
|
665
|
-
|
|
682
|
+
// Ignore: each measure already gets its own \relative c' block,
|
|
683
|
+
// and within a measure the LilyPond reference pitch is not reset.
|
|
666
684
|
break;
|
|
667
685
|
}
|
|
668
686
|
}
|
|
@@ -198,7 +198,10 @@ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number =
|
|
|
198
198
|
|
|
199
199
|
if (pitch.accidental) {
|
|
200
200
|
const noteAccid = ACCIDENTALS[pitch.accidental];
|
|
201
|
-
if (
|
|
201
|
+
if (pitch.courtesy) {
|
|
202
|
+
// Courtesy accidental (!) - always display
|
|
203
|
+
accid = noteAccid;
|
|
204
|
+
} else if (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
|
|
202
205
|
// Previous note in this measure had a different accidental - must re-assert
|
|
203
206
|
accid = noteAccid;
|
|
204
207
|
} else if (noteAccid !== keyAccid) {
|
|
@@ -211,13 +214,21 @@ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number =
|
|
|
211
214
|
if (measureAccidentals) measureAccidentals.set(pitchKey, noteAccid);
|
|
212
215
|
} else if (keyAccid) {
|
|
213
216
|
// Note has no accidental but key implies one - output natural
|
|
214
|
-
if (
|
|
217
|
+
if (pitch.courtesy) {
|
|
218
|
+
// Courtesy accidental (!) - always display
|
|
219
|
+
accid = 'n';
|
|
220
|
+
} else if (prevMeasureAccid === 'n') {
|
|
215
221
|
// Already cancelled earlier in this measure - no need to show again
|
|
216
222
|
} else {
|
|
217
223
|
accid = 'n';
|
|
218
224
|
}
|
|
219
225
|
accidGes = 'n';
|
|
220
226
|
if (measureAccidentals) measureAccidentals.set(pitchKey, 'n');
|
|
227
|
+
} else if (pitch.courtesy && prevMeasureAccid && prevMeasureAccid !== 'n') {
|
|
228
|
+
// Courtesy accidental after an in-measure accidental - force natural display
|
|
229
|
+
accid = 'n';
|
|
230
|
+
accidGes = 'n';
|
|
231
|
+
if (measureAccidentals) measureAccidentals.set(pitchKey, 'n');
|
|
221
232
|
} else if (measureAccidentals) {
|
|
222
233
|
// No explicit accidental, no key accidental - check if earlier note in measure had one
|
|
223
234
|
if (prevMeasureAccid && prevMeasureAccid !== 'n') {
|
|
@@ -603,11 +614,14 @@ const noteEventToMEI = (
|
|
|
603
614
|
|
|
604
615
|
|
|
605
616
|
// Convert RestEvent to MEI
|
|
606
|
-
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string
|
|
617
|
+
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): string => {
|
|
607
618
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
608
619
|
let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
|
|
609
620
|
if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
|
|
610
621
|
|
|
622
|
+
// Cross-staff attribute
|
|
623
|
+
if (crossStaff) attrs += ` staff="${crossStaff}"`;
|
|
624
|
+
|
|
611
625
|
// Pitched rest (positioned at specific pitch)
|
|
612
626
|
if (event.pitch) {
|
|
613
627
|
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
@@ -1189,9 +1203,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1189
1203
|
}
|
|
1190
1204
|
break;
|
|
1191
1205
|
}
|
|
1192
|
-
case 'rest':
|
|
1193
|
-
|
|
1206
|
+
case 'rest': {
|
|
1207
|
+
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1208
|
+
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1209
|
+
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1194
1210
|
break;
|
|
1211
|
+
}
|
|
1195
1212
|
case 'tuplet': {
|
|
1196
1213
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1197
1214
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
package/source/lilylet/types.ts
CHANGED
|
@@ -110,6 +110,7 @@ export interface Pitch {
|
|
|
110
110
|
phonet: Phonet;
|
|
111
111
|
accidental?: Accidental;
|
|
112
112
|
octave: number; // 0 = middle C octave, positive = higher, negative = lower
|
|
113
|
+
courtesy?: boolean; // force display of accidental (! in LilyPond)
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export interface Duration {
|