@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
|
@@ -587,10 +587,6 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
587
587
|
duration: convertDuration(term.durationValue),
|
|
588
588
|
invisible: term.isSpacer || undefined,
|
|
589
589
|
};
|
|
590
|
-
// Positioned rest
|
|
591
|
-
if (!term.isSpacer && context.pitch) {
|
|
592
|
-
restEvent.pitch = convertPitch(context.pitch.phonetStep, 0, context.pitch.octave);
|
|
593
|
-
}
|
|
594
590
|
voice.events.push(restEvent);
|
|
595
591
|
}
|
|
596
592
|
}
|
|
@@ -982,7 +978,7 @@ const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
|
|
|
982
978
|
const measures = parsedMeasures.map(pm => {
|
|
983
979
|
// Filter out voices that only contain spacer rests and context changes
|
|
984
980
|
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
985
|
-
// Group voices by partIndex, then
|
|
981
|
+
// Group voices by partIndex, then collect voice arrays per staff
|
|
986
982
|
const partMap = new Map();
|
|
987
983
|
for (const v of filteredVoices) {
|
|
988
984
|
const pi = v.partIndex || 1;
|
|
@@ -990,23 +986,23 @@ const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
|
|
|
990
986
|
partMap.set(pi, new Map());
|
|
991
987
|
}
|
|
992
988
|
const staffMap = partMap.get(pi);
|
|
993
|
-
//
|
|
989
|
+
// Preserve each voice as a separate array
|
|
994
990
|
if (!staffMap.has(v.staff)) {
|
|
995
991
|
staffMap.set(v.staff, []);
|
|
996
992
|
}
|
|
997
|
-
staffMap.get(v.staff).push(
|
|
993
|
+
staffMap.get(v.staff).push(v.events);
|
|
998
994
|
}
|
|
999
995
|
// Convert to parts array (sorted by part index, then by staff)
|
|
1000
|
-
// Apply deduplication to
|
|
996
|
+
// Apply deduplication to each voice's events
|
|
1001
997
|
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1002
998
|
const parts = partIndices.map(pi => {
|
|
1003
999
|
const staffMap = partMap.get(pi);
|
|
1004
1000
|
const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
|
|
1005
1001
|
return {
|
|
1006
|
-
voices: staffNums.
|
|
1002
|
+
voices: staffNums.flatMap(staff => staffMap.get(staff).map(events => ({
|
|
1007
1003
|
staff,
|
|
1008
|
-
events: dedupeContextEvents(
|
|
1009
|
-
})),
|
|
1004
|
+
events: dedupeContextEvents(events),
|
|
1005
|
+
}))),
|
|
1010
1006
|
};
|
|
1011
1007
|
});
|
|
1012
1008
|
// Fallback to single empty part if no voices
|
|
@@ -294,11 +294,19 @@ const encodeNoteEvent = (event, env, lastDuration) => {
|
|
|
294
294
|
if (event.pitches.length > 1) {
|
|
295
295
|
result += '<';
|
|
296
296
|
const pitchStrs = [];
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
let firstPitchEnv;
|
|
298
|
+
for (let i = 0; i < event.pitches.length; i++) {
|
|
299
|
+
// In LilyPond relative mode, each pitch in a chord is relative
|
|
300
|
+
// to the previous pitch in the chord (cascading).
|
|
301
|
+
// After the chord, env becomes the first pitch.
|
|
302
|
+
const { str, newEnv: ne } = encodePitch(event.pitches[i], newEnv);
|
|
299
303
|
pitchStrs.push(str);
|
|
300
304
|
newEnv = ne;
|
|
305
|
+
if (i === 0)
|
|
306
|
+
firstPitchEnv = ne;
|
|
301
307
|
}
|
|
308
|
+
// After chord, reference resets to first pitch
|
|
309
|
+
newEnv = firstPitchEnv;
|
|
302
310
|
result += pitchStrs.join(' ');
|
|
303
311
|
result += '>';
|
|
304
312
|
}
|
|
@@ -347,12 +355,14 @@ const encodeRestEvent = (event, env, lastDuration) => {
|
|
|
347
355
|
result += encodeDuration(event.duration);
|
|
348
356
|
}
|
|
349
357
|
// Positioned rest
|
|
358
|
+
let newEnv = env;
|
|
350
359
|
if (event.pitch && !event.fullMeasure && !event.invisible) {
|
|
351
|
-
const { str } = encodePitch(event.pitch, env);
|
|
360
|
+
const { str, newEnv: ne } = encodePitch(event.pitch, env);
|
|
352
361
|
result = str + result.slice(1); // Replace 'r' with pitch
|
|
353
362
|
result += '\\rest';
|
|
363
|
+
newEnv = ne;
|
|
354
364
|
}
|
|
355
|
-
return { str: result, newEnv
|
|
365
|
+
return { str: result, newEnv, newDuration: event.duration };
|
|
356
366
|
};
|
|
357
367
|
/**
|
|
358
368
|
* Encode a context change event
|
|
@@ -397,8 +407,9 @@ const encodeTupletEvent = (event, env, lastDuration) => {
|
|
|
397
407
|
newDuration = nd;
|
|
398
408
|
}
|
|
399
409
|
else if (subEvent.type === 'rest') {
|
|
400
|
-
const { str, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
|
|
410
|
+
const { str, newEnv: ne, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
|
|
401
411
|
result += str + ' ';
|
|
412
|
+
newEnv = ne;
|
|
402
413
|
newDuration = nd;
|
|
403
414
|
}
|
|
404
415
|
}
|
|
@@ -415,11 +426,15 @@ const encodeTremoloEvent = (event, env) => {
|
|
|
415
426
|
if (event.pitchA.length > 1) {
|
|
416
427
|
pitchA += '<';
|
|
417
428
|
const pitchStrs = [];
|
|
418
|
-
|
|
419
|
-
|
|
429
|
+
let firstPitchEnv;
|
|
430
|
+
for (let i = 0; i < event.pitchA.length; i++) {
|
|
431
|
+
const { str, newEnv: ne } = encodePitch(event.pitchA[i], newEnv);
|
|
420
432
|
pitchStrs.push(str);
|
|
421
433
|
newEnv = ne;
|
|
434
|
+
if (i === 0)
|
|
435
|
+
firstPitchEnv = ne;
|
|
422
436
|
}
|
|
437
|
+
newEnv = firstPitchEnv;
|
|
423
438
|
pitchA += pitchStrs.join(' ');
|
|
424
439
|
pitchA += '>';
|
|
425
440
|
}
|
|
@@ -433,11 +448,15 @@ const encodeTremoloEvent = (event, env) => {
|
|
|
433
448
|
if (event.pitchB.length > 1) {
|
|
434
449
|
pitchB += '<';
|
|
435
450
|
const pitchStrs = [];
|
|
436
|
-
|
|
437
|
-
|
|
451
|
+
let firstPitchEnv;
|
|
452
|
+
for (let i = 0; i < event.pitchB.length; i++) {
|
|
453
|
+
const { str, newEnv: ne } = encodePitch(event.pitchB[i], newEnv);
|
|
438
454
|
pitchStrs.push(str);
|
|
439
455
|
newEnv = ne;
|
|
456
|
+
if (i === 0)
|
|
457
|
+
firstPitchEnv = ne;
|
|
440
458
|
}
|
|
459
|
+
newEnv = firstPitchEnv;
|
|
441
460
|
pitchB += pitchStrs.join(' ');
|
|
442
461
|
pitchB += '>';
|
|
443
462
|
}
|
|
@@ -490,8 +509,9 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
|
|
|
490
509
|
break;
|
|
491
510
|
}
|
|
492
511
|
case 'rest': {
|
|
493
|
-
const { str, newDuration } = encodeRestEvent(event, env, lastDuration);
|
|
512
|
+
const { str, newEnv, newDuration } = encodeRestEvent(event, env, lastDuration);
|
|
494
513
|
result += str + ' ';
|
|
514
|
+
env = newEnv;
|
|
495
515
|
lastDuration = newDuration;
|
|
496
516
|
break;
|
|
497
517
|
}
|
|
@@ -528,7 +548,8 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
|
|
|
528
548
|
break;
|
|
529
549
|
}
|
|
530
550
|
case 'pitchReset': {
|
|
531
|
-
|
|
551
|
+
// Ignore: each measure already gets its own \relative c' block,
|
|
552
|
+
// and within a measure the LilyPond reference pitch is not reset.
|
|
532
553
|
break;
|
|
533
554
|
}
|
|
534
555
|
}
|
|
@@ -140,7 +140,11 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals)
|
|
|
140
140
|
const prevMeasureAccid = measureAccidentals?.get(pitchKey);
|
|
141
141
|
if (pitch.accidental) {
|
|
142
142
|
const noteAccid = ACCIDENTALS[pitch.accidental];
|
|
143
|
-
if (
|
|
143
|
+
if (pitch.courtesy) {
|
|
144
|
+
// Courtesy accidental (!) - always display
|
|
145
|
+
accid = noteAccid;
|
|
146
|
+
}
|
|
147
|
+
else if (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
|
|
144
148
|
// Previous note in this measure had a different accidental - must re-assert
|
|
145
149
|
accid = noteAccid;
|
|
146
150
|
}
|
|
@@ -156,7 +160,11 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals)
|
|
|
156
160
|
}
|
|
157
161
|
else if (keyAccid) {
|
|
158
162
|
// Note has no accidental but key implies one - output natural
|
|
159
|
-
if (
|
|
163
|
+
if (pitch.courtesy) {
|
|
164
|
+
// Courtesy accidental (!) - always display
|
|
165
|
+
accid = 'n';
|
|
166
|
+
}
|
|
167
|
+
else if (prevMeasureAccid === 'n') {
|
|
160
168
|
// Already cancelled earlier in this measure - no need to show again
|
|
161
169
|
}
|
|
162
170
|
else {
|
|
@@ -166,6 +174,13 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals)
|
|
|
166
174
|
if (measureAccidentals)
|
|
167
175
|
measureAccidentals.set(pitchKey, 'n');
|
|
168
176
|
}
|
|
177
|
+
else if (pitch.courtesy && prevMeasureAccid && prevMeasureAccid !== 'n') {
|
|
178
|
+
// Courtesy accidental after an in-measure accidental - force natural display
|
|
179
|
+
accid = 'n';
|
|
180
|
+
accidGes = 'n';
|
|
181
|
+
if (measureAccidentals)
|
|
182
|
+
measureAccidentals.set(pitchKey, 'n');
|
|
183
|
+
}
|
|
169
184
|
else if (measureAccidentals) {
|
|
170
185
|
// No explicit accidental, no key accidental - check if earlier note in measure had one
|
|
171
186
|
if (prevMeasureAccid && prevMeasureAccid !== 'n') {
|
|
@@ -483,11 +498,14 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
483
498
|
};
|
|
484
499
|
};
|
|
485
500
|
// Convert RestEvent to MEI
|
|
486
|
-
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
501
|
+
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
|
|
487
502
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
488
503
|
let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
|
|
489
504
|
if (event.duration.dots > 0)
|
|
490
505
|
attrs += ` dots="${event.duration.dots}"`;
|
|
506
|
+
// Cross-staff attribute
|
|
507
|
+
if (crossStaff)
|
|
508
|
+
attrs += ` staff="${crossStaff}"`;
|
|
491
509
|
// Pitched rest (positioned at specific pitch)
|
|
492
510
|
if (event.pitch) {
|
|
493
511
|
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
@@ -899,9 +917,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
899
917
|
}
|
|
900
918
|
break;
|
|
901
919
|
}
|
|
902
|
-
case 'rest':
|
|
903
|
-
|
|
920
|
+
case 'rest': {
|
|
921
|
+
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
922
|
+
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
923
|
+
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
904
924
|
break;
|
|
925
|
+
}
|
|
905
926
|
case 'tuplet': {
|
|
906
927
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
907
928
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
package/lib/lilylet/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.57",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|