@k-l-lambda/lilylet 0.1.56 → 0.1.58
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/lilypondDecoder.js +8 -11
- package/lib/lilylet/lilypondEncoder.js +32 -11
- package/lib/lilylet/serializer.js +16 -8
- package/package.json +1 -1
- package/source/lilylet/lilypondDecoder.ts +12 -18
- package/source/lilylet/lilypondEncoder.ts +29 -11
- package/source/lilylet/serializer.ts +15 -8
|
@@ -585,12 +585,9 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
585
585
|
const restEvent = {
|
|
586
586
|
type: 'rest',
|
|
587
587
|
duration: convertDuration(term.durationValue),
|
|
588
|
+
fullMeasure: (term.name === 'R') || undefined,
|
|
588
589
|
invisible: term.isSpacer || undefined,
|
|
589
590
|
};
|
|
590
|
-
// Positioned rest
|
|
591
|
-
if (!term.isSpacer && context.pitch) {
|
|
592
|
-
restEvent.pitch = convertPitch(context.pitch.phonetStep, 0, context.pitch.octave);
|
|
593
|
-
}
|
|
594
591
|
voice.events.push(restEvent);
|
|
595
592
|
}
|
|
596
593
|
}
|
|
@@ -982,7 +979,7 @@ const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
|
|
|
982
979
|
const measures = parsedMeasures.map(pm => {
|
|
983
980
|
// Filter out voices that only contain spacer rests and context changes
|
|
984
981
|
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
985
|
-
// Group voices by partIndex, then
|
|
982
|
+
// Group voices by partIndex, then collect voice arrays per staff
|
|
986
983
|
const partMap = new Map();
|
|
987
984
|
for (const v of filteredVoices) {
|
|
988
985
|
const pi = v.partIndex || 1;
|
|
@@ -990,23 +987,23 @@ const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
|
|
|
990
987
|
partMap.set(pi, new Map());
|
|
991
988
|
}
|
|
992
989
|
const staffMap = partMap.get(pi);
|
|
993
|
-
//
|
|
990
|
+
// Preserve each voice as a separate array
|
|
994
991
|
if (!staffMap.has(v.staff)) {
|
|
995
992
|
staffMap.set(v.staff, []);
|
|
996
993
|
}
|
|
997
|
-
staffMap.get(v.staff).push(
|
|
994
|
+
staffMap.get(v.staff).push(v.events);
|
|
998
995
|
}
|
|
999
996
|
// Convert to parts array (sorted by part index, then by staff)
|
|
1000
|
-
// Apply deduplication to
|
|
997
|
+
// Apply deduplication to each voice's events
|
|
1001
998
|
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1002
999
|
const parts = partIndices.map(pi => {
|
|
1003
1000
|
const staffMap = partMap.get(pi);
|
|
1004
1001
|
const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
|
|
1005
1002
|
return {
|
|
1006
|
-
voices: staffNums.
|
|
1003
|
+
voices: staffNums.flatMap(staff => staffMap.get(staff).map(events => ({
|
|
1007
1004
|
staff,
|
|
1008
|
-
events: dedupeContextEvents(
|
|
1009
|
-
})),
|
|
1005
|
+
events: dedupeContextEvents(events),
|
|
1006
|
+
}))),
|
|
1010
1007
|
};
|
|
1011
1008
|
});
|
|
1012
1009
|
// 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
|
}
|
|
@@ -500,10 +500,14 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
500
500
|
for (const event of voice.events) {
|
|
501
501
|
if (event.type === 'context') {
|
|
502
502
|
const ctx = event;
|
|
503
|
-
//
|
|
504
|
-
if (ctx.staff && ctx.staff !==
|
|
503
|
+
// Cross-staff context: update activeStaff and emit \staff directive
|
|
504
|
+
if (ctx.staff && ctx.staff !== activeStaff) {
|
|
505
|
+
activeStaff = ctx.staff;
|
|
506
|
+
parts.push('\\staff "' + activeStaff + '"');
|
|
505
507
|
continue;
|
|
506
508
|
}
|
|
509
|
+
if (ctx.staff)
|
|
510
|
+
continue; // same staff, no-op
|
|
507
511
|
// Skip clef-only context events if clef already established for this staff
|
|
508
512
|
if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
|
|
509
513
|
continue;
|
|
@@ -511,8 +515,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
511
515
|
}
|
|
512
516
|
if (event.type === 'note') {
|
|
513
517
|
const noteEvt = event;
|
|
514
|
-
// Cross-staff
|
|
515
|
-
const effectiveStaff = noteEvt.staff ||
|
|
518
|
+
// Cross-staff via explicit note.staff (lilylet native cross-staff)
|
|
519
|
+
const effectiveStaff = noteEvt.staff || activeStaff;
|
|
516
520
|
if (effectiveStaff !== activeStaff) {
|
|
517
521
|
activeStaff = effectiveStaff;
|
|
518
522
|
parts.push('\\staff "' + activeStaff + '"');
|
|
@@ -679,12 +683,16 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
679
683
|
// Collect clefs from this measure's voices
|
|
680
684
|
for (const part of measure.parts) {
|
|
681
685
|
for (const voice of part.voices) {
|
|
686
|
+
let clefActiveStaff = voice.staff;
|
|
682
687
|
for (const event of voice.events) {
|
|
683
|
-
if (event.type === 'context'
|
|
688
|
+
if (event.type === 'context') {
|
|
684
689
|
const ctx = event;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
690
|
+
if (ctx.staff) {
|
|
691
|
+
clefActiveStaff = ctx.staff;
|
|
692
|
+
}
|
|
693
|
+
if (ctx.clef) {
|
|
694
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
695
|
+
}
|
|
688
696
|
}
|
|
689
697
|
}
|
|
690
698
|
}
|
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.58",
|
|
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",
|
|
@@ -708,18 +708,10 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
708
708
|
const restEvent: RestEvent = {
|
|
709
709
|
type: 'rest',
|
|
710
710
|
duration: convertDuration(term.durationValue),
|
|
711
|
+
fullMeasure: (term.name === 'R') || undefined,
|
|
711
712
|
invisible: term.isSpacer || undefined,
|
|
712
713
|
};
|
|
713
714
|
|
|
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
715
|
voice.events.push(restEvent);
|
|
724
716
|
}
|
|
725
717
|
}
|
|
@@ -1145,8 +1137,8 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
|
|
|
1145
1137
|
// Filter out voices that only contain spacer rests and context changes
|
|
1146
1138
|
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
1147
1139
|
|
|
1148
|
-
// Group voices by partIndex, then
|
|
1149
|
-
const partMap = new Map<number, Map<number, Event[]>>();
|
|
1140
|
+
// Group voices by partIndex, then collect voice arrays per staff
|
|
1141
|
+
const partMap = new Map<number, Map<number, Event[][]>>();
|
|
1150
1142
|
for (const v of filteredVoices) {
|
|
1151
1143
|
const pi = v.partIndex || 1;
|
|
1152
1144
|
if (!partMap.has(pi)) {
|
|
@@ -1154,24 +1146,26 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
|
|
|
1154
1146
|
}
|
|
1155
1147
|
const staffMap = partMap.get(pi)!;
|
|
1156
1148
|
|
|
1157
|
-
//
|
|
1149
|
+
// Preserve each voice as a separate array
|
|
1158
1150
|
if (!staffMap.has(v.staff)) {
|
|
1159
1151
|
staffMap.set(v.staff, []);
|
|
1160
1152
|
}
|
|
1161
|
-
staffMap.get(v.staff)!.push(
|
|
1153
|
+
staffMap.get(v.staff)!.push(v.events);
|
|
1162
1154
|
}
|
|
1163
1155
|
|
|
1164
1156
|
// Convert to parts array (sorted by part index, then by staff)
|
|
1165
|
-
// Apply deduplication to
|
|
1157
|
+
// Apply deduplication to each voice's events
|
|
1166
1158
|
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1167
1159
|
const parts = partIndices.map(pi => {
|
|
1168
1160
|
const staffMap = partMap.get(pi)!;
|
|
1169
1161
|
const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
|
|
1170
1162
|
return {
|
|
1171
|
-
voices: staffNums.
|
|
1172
|
-
staff
|
|
1173
|
-
|
|
1174
|
-
|
|
1163
|
+
voices: staffNums.flatMap(staff =>
|
|
1164
|
+
staffMap.get(staff)!.map(events => ({
|
|
1165
|
+
staff,
|
|
1166
|
+
events: dedupeContextEvents(events),
|
|
1167
|
+
}))
|
|
1168
|
+
),
|
|
1175
1169
|
};
|
|
1176
1170
|
});
|
|
1177
1171
|
|
|
@@ -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
|
}
|
|
@@ -648,10 +648,13 @@ const serializeVoice = (
|
|
|
648
648
|
for (const event of voice.events) {
|
|
649
649
|
if (event.type === 'context') {
|
|
650
650
|
const ctx = event as ContextChange;
|
|
651
|
-
//
|
|
652
|
-
if (ctx.staff && ctx.staff !==
|
|
651
|
+
// Cross-staff context: update activeStaff and emit \staff directive
|
|
652
|
+
if (ctx.staff && ctx.staff !== activeStaff) {
|
|
653
|
+
activeStaff = ctx.staff;
|
|
654
|
+
parts.push('\\staff "' + activeStaff + '"');
|
|
653
655
|
continue;
|
|
654
656
|
}
|
|
657
|
+
if (ctx.staff) continue; // same staff, no-op
|
|
655
658
|
// Skip clef-only context events if clef already established for this staff
|
|
656
659
|
if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
|
|
657
660
|
continue;
|
|
@@ -661,8 +664,8 @@ const serializeVoice = (
|
|
|
661
664
|
if (event.type === 'note') {
|
|
662
665
|
const noteEvt = event as NoteEvent;
|
|
663
666
|
|
|
664
|
-
// Cross-staff
|
|
665
|
-
const effectiveStaff = noteEvt.staff ||
|
|
667
|
+
// Cross-staff via explicit note.staff (lilylet native cross-staff)
|
|
668
|
+
const effectiveStaff = noteEvt.staff || activeStaff;
|
|
666
669
|
if (effectiveStaff !== activeStaff) {
|
|
667
670
|
activeStaff = effectiveStaff;
|
|
668
671
|
parts.push('\\staff "' + activeStaff + '"');
|
|
@@ -872,12 +875,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
872
875
|
// Collect clefs from this measure's voices
|
|
873
876
|
for (const part of measure.parts) {
|
|
874
877
|
for (const voice of part.voices) {
|
|
878
|
+
let clefActiveStaff = voice.staff;
|
|
875
879
|
for (const event of voice.events) {
|
|
876
|
-
if (event.type === 'context'
|
|
880
|
+
if (event.type === 'context') {
|
|
877
881
|
const ctx = event as ContextChange;
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
882
|
+
if (ctx.staff) {
|
|
883
|
+
clefActiveStaff = ctx.staff;
|
|
884
|
+
}
|
|
885
|
+
if (ctx.clef) {
|
|
886
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
887
|
+
}
|
|
881
888
|
}
|
|
882
889
|
}
|
|
883
890
|
}
|