@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.
@@ -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 merge voices on the same staff
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
- // Merge events from voices on the same staff
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(...v.events);
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 merged events
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.map(staff => ({
1002
+ voices: staffNums.flatMap(staff => staffMap.get(staff).map(events => ({
1007
1003
  staff,
1008
- events: dedupeContextEvents(staffMap.get(staff)),
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
- for (const pitch of event.pitches) {
298
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
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: env, newDuration: event.duration };
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
- for (const pitch of event.pitchA) {
419
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
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
- for (const pitch of event.pitchB) {
437
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
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
- env = { step: 0, octave: 0 };
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 (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
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 (prevMeasureAccid === 'n') {
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
- xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
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
@@ -91,6 +91,7 @@ export interface Pitch {
91
91
  phonet: Phonet;
92
92
  accidental?: Accidental;
93
93
  octave: number;
94
+ courtesy?: boolean;
94
95
  }
95
96
  export interface Duration {
96
97
  division: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.55",
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",