@k-l-lambda/lilylet 0.1.54 → 0.1.56

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.
@@ -396,7 +396,9 @@ pitches
396
396
  ;
397
397
 
398
398
  pitch
399
- : PITCH octave -> parsePitch($1, $2)
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
  ;
@@ -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 (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
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 (prevMeasureAccid === 'n') {
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>): 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
- xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
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
@@ -1236,9 +1253,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1236
1253
  case 'context': {
1237
1254
  const ctx = event as ContextChange;
1238
1255
  // Check for clef changes - emit <clef> element only if different from current
1256
+ // and only when on the home staff (don't emit cross-staff clefs into this layer)
1239
1257
  if (ctx.clef && ctx.clef !== currentClef) {
1240
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
1241
- xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
1258
+ const layerStaff = voice.staff || 1;
1259
+ if (currentStaff === layerStaff) {
1260
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
1261
+ xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
1262
+ }
1242
1263
  currentClef = ctx.clef;
1243
1264
  }
1244
1265
  // Check for ottava changes
@@ -1832,16 +1853,20 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
1832
1853
  for (let pi = 0; pi < measure.parts.length; pi++) {
1833
1854
  const part = measure.parts[pi];
1834
1855
  for (const voice of part.voices) {
1835
- const localStaff = voice.staff || 1;
1836
- partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, localStaff);
1856
+ let currentStaff = voice.staff || 1;
1857
+ partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, currentStaff);
1837
1858
 
1838
- // Get FIRST clef from context changes (for initial staffDef)
1859
+ // Scan context changes for staff switches and clefs
1839
1860
  for (const event of voice.events) {
1840
1861
  if (event.type === 'context') {
1841
1862
  const ctx = event as ContextChange;
1842
- if (ctx.clef && !partInfos[pi].clefs[localStaff]) {
1843
- // Only set if not already set - take the FIRST clef
1844
- partInfos[pi].clefs[localStaff] = ctx.clef;
1863
+ if (ctx.staff !== undefined) {
1864
+ currentStaff = ctx.staff;
1865
+ partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, currentStaff);
1866
+ }
1867
+ if (ctx.clef && !partInfos[pi].clefs[currentStaff]) {
1868
+ // Only set if not already set - take the FIRST clef per staff
1869
+ partInfos[pi].clefs[currentStaff] = ctx.clef;
1845
1870
  }
1846
1871
  }
1847
1872
  }
@@ -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 {