@k-l-lambda/lilylet 0.1.68 → 0.1.69

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.
@@ -681,7 +681,7 @@ const processBarPatch = (patch, unitLength, slurDepth, pitchCtx) => {
681
681
  }
682
682
  // Grace notes
683
683
  if (term.grace) {
684
- const graceEvents = convertGraceEvents(term, unitLength);
684
+ const graceEvents = convertGraceEvents(term, unitLength, pitchCtx);
685
685
  events.push(...graceEvents);
686
686
  i++;
687
687
  continue;
@@ -852,7 +852,7 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
852
852
  /**
853
853
  * Convert grace notes to NoteEvents with grace flag
854
854
  */
855
- const convertGraceEvents = (graceTerm, unitLength) => {
855
+ const convertGraceEvents = (graceTerm, unitLength, pitchCtx) => {
856
856
  const events = [];
857
857
  if (!graceTerm.events)
858
858
  return events;
@@ -862,7 +862,7 @@ const convertGraceEvents = (graceTerm, unitLength) => {
862
862
  const chord = eventData.chord;
863
863
  if (!chord || !chord.pitches)
864
864
  continue;
865
- const pitches = chord.pitches.filter((p) => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x").map(convertPitch);
865
+ const pitches = chord.pitches.filter((p) => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x").map((p) => convertPitch(p, pitchCtx));
866
866
  if (pitches.length === 0)
867
867
  continue;
868
868
  const duration = convertDuration(eventData.duration, unitLength);
@@ -199,11 +199,12 @@ const serializeMarks = (marks) => {
199
199
  return parts.join('');
200
200
  };
201
201
  // Serialize a note event with pitch environment tracking
202
- const serializeNoteEvent = (event, env, prevDuration) => {
202
+ const serializeNoteEvent = (event, env, prevDuration, suppressGracePrefix = false) => {
203
203
  const parts = [];
204
204
  let currentEnv = env;
205
- // Grace note prefix
206
- if (event.grace) {
205
+ // Grace note prefix. When the caller groups consecutive grace notes into a single
206
+ // scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
207
+ if (event.grace && !suppressGracePrefix) {
207
208
  parts.push('\\grace ');
208
209
  }
209
210
  // Single note or chord
@@ -440,10 +441,10 @@ const serializeBarlineEvent = (event) => {
440
441
  return '';
441
442
  };
442
443
  // Serialize a single event with pitch environment tracking
443
- const serializeEvent = (event, env, prevDuration) => {
444
+ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) => {
444
445
  switch (event.type) {
445
446
  case 'note':
446
- return serializeNoteEvent(event, env, prevDuration);
447
+ return serializeNoteEvent(event, env, prevDuration, suppressGracePrefix);
447
448
  case 'rest':
448
449
  return serializeRestEvent(event, env, prevDuration);
449
450
  case 'context':
@@ -576,6 +577,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
576
577
  }
577
578
  let activeStaff = effectiveInitialStaff;
578
579
  let activeStemDir;
580
+ let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
579
581
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
580
582
  const event = voice.events[eventIdx];
581
583
  // Skip leading context-staff events already absorbed into effectiveInitialStaff
@@ -639,7 +641,18 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
639
641
  activeStemDir = stemDir;
640
642
  }
641
643
  }
642
- const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
644
+ const isGraceNote = event.type === 'note' && !!event.grace;
645
+ // Group consecutive grace notes into one scoped \grace { ... } instead of
646
+ // emitting a separate \grace prefix per note.
647
+ if (isGraceNote && !graceGroupOpen) {
648
+ parts.push('\\grace {');
649
+ graceGroupOpen = true;
650
+ }
651
+ else if (!isGraceNote && graceGroupOpen) {
652
+ parts.push('}');
653
+ graceGroupOpen = false;
654
+ }
655
+ const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
643
656
  pitchEnv = newEnv;
644
657
  if (eventStr) {
645
658
  parts.push(eventStr);
@@ -663,6 +676,10 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
663
676
  emittedClefs[ctx.staff || activeStaff] = ctx.clef;
664
677
  }
665
678
  }
679
+ // Close a grace group left open at the end of the voice (unusual but possible).
680
+ if (graceGroupOpen) {
681
+ parts.push('}');
682
+ }
666
683
  return { str: parts.join(' '), newStaff: voice.staff };
667
684
  };
668
685
  // Serialize a part, tracking staff state across voices
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
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",
@@ -37,14 +37,14 @@
37
37
 
38
38
  const patch = (terms, bar) => {
39
39
  const control = {};
40
- terms.forEach(term => {
40
+ (terms || []).forEach(term => {
41
41
  if (term.control)
42
42
  control[term.control.name] = term.control.value;
43
43
  });
44
44
 
45
45
  return {
46
46
  control,
47
- terms,
47
+ terms: terms || [],
48
48
  bar,
49
49
  };
50
50
  };
@@ -195,10 +195,10 @@ Am \b[A-G](?=[m][a][j]|[m][i][n]\b)
195
195
  a \b[a-g](?=[\W\d\sA-Ga-g_yzHJLMOPRSTuv]*\b)
196
196
  z \b[z]
197
197
  Z \b[Z]
198
- x \b[x](?=[\W\d\s])
198
+ x \b[x](?=[\W\d\s]|[_^=]*[A-Ga-g])
199
199
  y \b[y]
200
200
  N [0-9]
201
- P \b[HJLMOPRSTuv](?=[A-Ga-g][A-Ga-g0-9]*\b)
201
+ P \b[HJLMOPRSTuv](?=[_^=]*[A-Ga-g][A-Ga-g0-9_^=,']*)
202
202
  PP \b[HJLMOPRSTuv](?=[xz!\[^_=\s"])
203
203
 
204
204
  SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
@@ -211,8 +211,8 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
211
211
  <string>\\\" return 'STR_CONTENT'
212
212
  <string>[^"]+ return 'STR_CONTENT'
213
213
 
214
- ^[T][:][\s]* { this.pushState('title_string'); return 'T:'; }
215
- ^[C][:][\s]* { this.pushState('title_string'); return 'C:'; }
214
+ ^[T][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'T:'; } this.unput(yytext.slice(1)); yytext = 'T'; return 'T'; }
215
+ ^[C][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'C:'; } this.unput(yytext.slice(1)); yytext = 'C'; return 'A'; }
216
216
  <title_string>\n { this.popState(); }
217
217
  <title_string>[^\n]+ return 'STR_CONTENT'
218
218
 
@@ -226,10 +226,10 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
226
226
  <voice_header>[ \t]+ {}
227
227
  <voice_header>\n { this.popState(); }
228
228
  <voice_header>\] { this.popState(); return ']'; }
229
- <key_signature>"treble" return 'TREBLE';
230
- <key_signature>"bass" return 'BASS';
231
- <key_signature>"tenor" return 'TENOR';
232
- <key_signature>"alto" return 'ALTO';
229
+ <key_signature>"treble"[0-9]* return 'TREBLE';
230
+ <key_signature>"bass"[0-9]* return 'BASS';
231
+ <key_signature>"tenor"[0-9]* return 'TENOR';
232
+ <key_signature>"alto"[0-9]* return 'ALTO';
233
233
  <key_signature>"none" return 'NAME';
234
234
  <key_signature>"Dor" return 'NAME';
235
235
  <key_signature>"Phr" return 'NAME';
@@ -262,9 +262,10 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
262
262
  <spec_comment_name>[\w]+ { this.popState(); this.pushState('spec_comment_skip'); }
263
263
  <spec_comment_name>\n { this.popState(); this.popState(); }
264
264
  <spec_comment>[ \t]+ {}
265
- <spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
265
+ <spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
266
266
  <spec_comment>[)\]}] { this._scoreDepth = (this._scoreDepth || 0) - 1; return yytext; }
267
267
  <spec_comment>[|] return yytext
268
+ <spec_comment>[A-Z][:] { this.popState(); this.popState(); this.unput(yytext); return 'LAYOUT_END'; }
268
269
  <spec_comment>[\w]+ return 'NN'
269
270
  <spec_comment>\n { if (this._scoreDepth > 0) { /* layout continues on next line */ } else { this.popState(); this.popState(); return 'LAYOUT_END'; } }
270
271
  <spec_comment_skip>[^\n]+ {}
@@ -467,6 +468,7 @@ numeric_tempo
467
468
 
468
469
  voice_exp
469
470
  : number -> voice($1)
471
+ | number assigns -> voice($1, null, $2)
470
472
  | number NAME -> voice($1, $2)
471
473
  | number NAME assigns -> voice($1, $2, $3)
472
474
  | number NAME plus_minus_number -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3))
@@ -539,10 +541,18 @@ bar
539
541
  | '|' ']' ':' -> '|]'
540
542
  | ':' '|' ']' -> ':|]'
541
543
  | '|' N -> '|' + $2
544
+ | '|' N volta_rest -> '|' + $2 + $3
542
545
  | ':' '|' N -> ':|' + $2
546
+ | ':' '|' N volta_rest -> ':|' + $2 + $3
543
547
  | '&' -> '&'
544
548
  ;
545
549
 
550
+ // Additional volta endings after the first number, comma-separated: "1,2", "1,2,3".
551
+ volta_rest
552
+ : ',' N -> ',' + $2
553
+ | volta_rest ',' N -> $1 + ',' + $3
554
+ ;
555
+
546
556
  music
547
557
  : %empty -> []
548
558
  | music expressive_mark -> $1 ? [...$1, $2] : [$2]
@@ -555,9 +565,17 @@ music
555
565
  | music N -> $1
556
566
  | music NAME -> $1
557
567
  | music '^' NAME -> $1
568
+ | music '^' articulation_letter -> $1
558
569
  | music '[' N -> $1
559
570
  ;
560
571
 
572
+ // Articulation-class letters (P macro: HJLMOPRSTuv). After a stray '^' inside a
573
+ // text annotation, a word like "Menuetto"/"Largo" starts with one of these and the
574
+ // lexer emits it as the articulation token, not NAME — swallow it like '^' NAME.
575
+ articulation_letter
576
+ : 'P' | 'T' | 'H' | 'J' | 'L' | 'M' | 'R' | 'O' | 'S' | 'u' | 'v'
577
+ ;
578
+
561
579
  control
562
580
  : '[' H ':' header_value ']' -> ({control: header($2, $4)})
563
581
  | '[' 'K:' header_value ']' -> ({control: header("K", $3)})
@@ -756,7 +774,14 @@ duration
756
774
  : number '/' number -> frac(Number($1), Number($3))
757
775
  | '/' number -> frac(1, Number($2))
758
776
  | number -> frac(Number($1))
759
- | '/' -> frac(1, 2)
777
+ | slashes -> frac(1, Math.pow(2, $1))
778
+ | number slashes -> frac(Number($1), Math.pow(2, $2))
779
+ ;
780
+
781
+ // One or more '/' halve the unit length each: '/'=1/2, '//'=1/4, '///'=1/8.
782
+ slashes
783
+ : '/' -> 1
784
+ | slashes '/' -> $1 + 1
760
785
  ;
761
786
 
762
787
  broken_rhythm