@k-l-lambda/lilylet 0.1.67 → 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.
@@ -538,7 +538,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
538
538
  // Convert RestEvent to MEI
539
539
  const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
540
540
  const dur = DURATIONS[event.duration.division] || "4";
541
- let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
541
+ const restId = generateId('rest');
542
+ let attrs = `xml:id="${restId}" dur="${dur}"`;
542
543
  if (event.duration.dots > 0)
543
544
  attrs += ` dots="${event.duration.dots}"`;
544
545
  // Cross-staff attribute
@@ -551,13 +552,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
551
552
  }
552
553
  // Space rest (invisible)
553
554
  if (event.invisible) {
554
- return `${indent}<space ${attrs} />\n`;
555
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
555
556
  }
556
557
  // Full measure rest
557
558
  if (event.fullMeasure) {
558
- return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
559
+ const mRestId = generateId('mrest');
560
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
559
561
  }
560
- return `${indent}<rest ${attrs} />\n`;
562
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
561
563
  };
562
564
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
563
565
  // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
@@ -645,7 +647,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
645
647
  }
646
648
  else if (e.type === 'rest') {
647
649
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
648
- xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
650
+ xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
649
651
  }
650
652
  else if (e.type === 'context') {
651
653
  const ctx = e;
@@ -809,6 +811,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
809
811
  const barlines = [];
810
812
  const markups = [];
811
813
  const pendingMarkups = [];
814
+ const pendingDynamics = [];
812
815
  // Track current stem direction from context changes
813
816
  let currentStemDirection = undefined;
814
817
  // Track current staff for cross-staff notation
@@ -824,6 +827,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
824
827
  }
825
828
  pendingMarkups.length = 0;
826
829
  };
830
+ // Helper to flush pending leading dynamics onto a note ID
831
+ const flushPendingDynamics = (noteId) => {
832
+ for (const label of pendingDynamics) {
833
+ dynamics.push({ startid: noteId, label });
834
+ }
835
+ pendingDynamics.length = 0;
836
+ };
827
837
  // Helper to check if pitches match for tie continuation
828
838
  const pitchesMatch = (p1, p2) => {
829
839
  if (p1.length !== p2.length)
@@ -884,6 +894,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
884
894
  }
885
895
  // Flush any pending markups onto this note
886
896
  flushPendingMarkups(result.elementId);
897
+ // Flush any pending leading dynamics onto this note
898
+ flushPendingDynamics(result.elementId);
887
899
  // If there's a pending ottava, start the span on this note
888
900
  if (pendingOttava !== null && pendingOttava !== 0) {
889
901
  const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -996,7 +1008,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
996
1008
  case 'rest': {
997
1009
  // For cross-staff notation: pass staff number if different from voice's home staff
998
1010
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
999
- xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1011
+ const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1012
+ xml += restResult.xml;
1013
+ // A leading dynamic/markup attaches to the next event, which may be this rest
1014
+ flushPendingMarkups(restResult.elementId);
1015
+ flushPendingDynamics(restResult.elementId);
1016
+ lastNoteId = restResult.elementId;
1000
1017
  break;
1001
1018
  }
1002
1019
  case 'tuplet':
@@ -1012,6 +1029,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1012
1029
  // Flush any pending markups onto the first note of the tuplet
1013
1030
  if (tupletResult.firstNoteId) {
1014
1031
  flushPendingMarkups(tupletResult.firstNoteId);
1032
+ flushPendingDynamics(tupletResult.firstNoteId);
1015
1033
  lastNoteId = tupletResult.firstNoteId;
1016
1034
  }
1017
1035
  // Process slur ends first (to close any pending slurs from before this tuplet)
@@ -1127,6 +1145,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1127
1145
  }
1128
1146
  }
1129
1147
  break;
1148
+ case 'dynamic':
1149
+ {
1150
+ // Standalone (leading) dynamic - attaches to the following note
1151
+ const dynEvent = event;
1152
+ pendingDynamics.push(dynEvent.dynamicType);
1153
+ }
1154
+ break;
1130
1155
  }
1131
1156
  // Close beam element if beam ends
1132
1157
  if (beamEnd) {
@@ -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':
@@ -455,12 +456,25 @@ const serializeEvent = (event, env, prevDuration) => {
455
456
  return serializeTremoloEvent(event, env);
456
457
  case 'barline':
457
458
  return { str: serializeBarlineEvent(event), newEnv: env };
459
+ case 'markup': {
460
+ const mk = event;
461
+ const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
462
+ return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
463
+ }
464
+ case 'dynamic': {
465
+ const dynStr = DYNAMIC_MAP[event.dynamicType];
466
+ return { str: dynStr || '', newEnv: env };
467
+ }
458
468
  default:
459
469
  return { str: '', newEnv: env };
460
470
  }
461
471
  };
462
- // Find first clef in voice events
472
+ // Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
473
+ // a clef context event that appears before any musical event on the home staff.
474
+ // A clef that first appears AFTER a note is a mid-voice change and must be emitted
475
+ // inline where it occurs, not hoisted to the front — so it is not returned here.
463
476
  const findVoiceClef = (voice) => {
477
+ const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
464
478
  let activeStaff = voice.staff;
465
479
  for (const event of voice.events) {
466
480
  if (event.type === 'context') {
@@ -471,6 +485,11 @@ const findVoiceClef = (voice) => {
471
485
  return ctx.clef;
472
486
  }
473
487
  }
488
+ else if (MUSICAL.has(event.type)) {
489
+ // Reached music on the home staff before any clef — no leading clef.
490
+ if (activeStaff === voice.staff)
491
+ return undefined;
492
+ }
474
493
  }
475
494
  return undefined;
476
495
  };
@@ -490,7 +509,6 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
490
509
  // before any music collapse to the last one (earlier ones are no-ops).
491
510
  // leadStaffScanEnd is the index of the first event that ends this scan —
492
511
  // context{staff} events before this index are skipped in the main loop.
493
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
494
512
  let effectiveInitialStaff = voice.staff;
495
513
  let leadStaffScanEnd = 0;
496
514
  for (let i = 0; i < voice.events.length; i++) {
@@ -516,8 +534,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
516
534
  leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
517
535
  continue;
518
536
  }
519
- if (MUSICAL_TYPES.has(e.type))
520
- break;
537
+ // Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
538
+ // visible position; a staff switch after it must not be hoisted ahead of it.
539
+ break;
521
540
  }
522
541
  // Output staff command if voice staff differs from current parser staff,
523
542
  // or always output if it's a grand staff score for clarity.
@@ -545,18 +564,20 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
545
564
  parts.push('\\time ' + numerator + '/' + denominator);
546
565
  }
547
566
  }
548
- // Output clef only if not yet emitted or changed for this staff
549
- const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
567
+ // Output clef only if not yet emitted or changed for this staff.
568
+ // Prefer this voice's leading clef (a clef before any music); fall back to the
569
+ // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
570
+ // hoisted here — it is emitted inline at its position.
571
+ const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
550
572
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
551
573
  if (voiceClef && !clefAlreadyEmitted) {
552
574
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
553
575
  if (emittedClefs)
554
576
  emittedClefs[voice.staff] = voiceClef;
555
577
  }
556
- // Skip redundant clef context events if this staff's clef is already established
557
- const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
558
578
  let activeStaff = effectiveInitialStaff;
559
579
  let activeStemDir;
580
+ let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
560
581
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
561
582
  const event = voice.events[eventIdx];
562
583
  // Skip leading context-staff events already absorbed into effectiveInitialStaff
@@ -581,8 +602,11 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
581
602
  if (ctx.staff && !ctx.clef && !ctx.ottava)
582
603
  continue; // same staff, pure no-op
583
604
  if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
584
- // Skip clef-only context events if clef already established for this staff
585
- if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
605
+ // Skip a clef-only context event only if it is REDUNDANT i.e. it restates the
606
+ // clef already active for this staff. A clef that differs is a genuine change and
607
+ // must be emitted inline at its position.
608
+ if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
609
+ emittedClefs?.[ctx.staff || activeStaff] === ctx.clef) {
586
610
  continue;
587
611
  }
588
612
  }
@@ -617,7 +641,18 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
617
641
  activeStemDir = stemDir;
618
642
  }
619
643
  }
620
- 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);
621
656
  pitchEnv = newEnv;
622
657
  if (eventStr) {
623
658
  parts.push(eventStr);
@@ -641,6 +676,10 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
641
676
  emittedClefs[ctx.staff || activeStaff] = ctx.clef;
642
677
  }
643
678
  }
679
+ // Close a grace group left open at the end of the voice (unusual but possible).
680
+ if (graceGroupOpen) {
681
+ parts.push('}');
682
+ }
644
683
  return { str: parts.join(' '), newStaff: voice.staff };
645
684
  };
646
685
  // Serialize a part, tracking staff state across voices
@@ -775,25 +814,31 @@ export const serializeLilyletDoc = (doc) => {
775
814
  if (measure.timeSig) {
776
815
  currentTime = measure.timeSig;
777
816
  }
778
- // Collect clefs from this measure's voices, per part
779
- measure.parts.forEach((part, pi) => {
780
- const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
781
- for (const voice of part.voices) {
782
- let clefActiveStaff = voice.staff;
783
- for (const event of voice.events) {
784
- if (event.type === 'context') {
785
- const ctx = event;
786
- if (ctx.staff) {
787
- clefActiveStaff = ctx.staff;
788
- }
789
- if (ctx.clef) {
790
- staffClefs[clefActiveStaff] = ctx.clef;
817
+ // Collect clefs from this measure's voices, per part — but only AFTER serializing
818
+ // the measure, so that during serialization allStaffClefs reflects the clef state
819
+ // CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
820
+ // to the measure's front; it is emitted inline at its position).
821
+ const collectClefs = () => {
822
+ measure.parts.forEach((part, pi) => {
823
+ const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
824
+ for (const voice of part.voices) {
825
+ let clefActiveStaff = voice.staff;
826
+ for (const event of voice.events) {
827
+ if (event.type === 'context') {
828
+ const ctx = event;
829
+ if (ctx.staff) {
830
+ clefActiveStaff = ctx.staff;
831
+ }
832
+ if (ctx.clef) {
833
+ staffClefs[clefActiveStaff] = ctx.clef;
834
+ }
791
835
  }
792
836
  }
793
837
  }
794
- }
795
- });
838
+ });
839
+ };
796
840
  const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
841
+ collectClefs();
797
842
  // Always include measure, even if empty (use space rest for empty measures)
798
843
  measureStrs.push(measureStr || 's1');
799
844
  currentStaff = newStaff;
@@ -221,7 +221,11 @@ export interface MarkupEvent {
221
221
  content: string;
222
222
  placement?: Placement;
223
223
  }
224
- export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
224
+ export interface DynamicEvent {
225
+ type: 'dynamic';
226
+ dynamicType: DynamicType;
227
+ }
228
+ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
225
229
  export interface Voice {
226
230
  staff: number;
227
231
  events: Event[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.67",
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",
@@ -30,7 +30,6 @@
30
30
  "test": "tsx ./tests/parser.ts",
31
31
  "test:mei": "tsx ./tests/mei.ts",
32
32
  "test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
33
- "test:unit": "tsx ./tests/unit/encodePitch.test.ts",
34
33
  "test:partial": "tsx ./tests/unit/partialWarning.test.ts",
35
34
  "test:decoder": "tsx ./tests/lilypondDecoder.ts",
36
35
  "test:abc": "tsx ./tests/abc-decoder.ts",
@@ -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';
@@ -239,7 +239,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
239
239
  <key_signature>"Loc" return 'NAME';
240
240
  <key_signature>"HP" return 'NAME';
241
241
  <key_signature>"Hp" return 'NAME';
242
- <key_signature>[a-z]+[ \t]*=[^\n\]]* {}
242
+ <key_signature>[a-z]+[ \t]*[=][^\n\]]* {}
243
243
  <key_signature>[A-G] return 'A';
244
244
  <key_signature>[A-Z][a-z]+ return 'NAME';
245
245
  <key_signature>[b] return 'FLAT';
@@ -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,10 +565,17 @@ music
555
565
  | music N -> $1
556
566
  | music NAME -> $1
557
567
  | music '^' NAME -> $1
558
- | music '^' -> $1
568
+ | music '^' articulation_letter -> $1
559
569
  | music '[' N -> $1
560
570
  ;
561
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
+
562
579
  control
563
580
  : '[' H ':' header_value ']' -> ({control: header($2, $4)})
564
581
  | '[' 'K:' header_value ']' -> ({control: header("K", $3)})
@@ -757,7 +774,14 @@ duration
757
774
  : number '/' number -> frac(Number($1), Number($3))
758
775
  | '/' number -> frac(1, Number($2))
759
776
  | number -> frac(Number($1))
760
- | '/' -> 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
761
785
  ;
762
786
 
763
787
  broken_rhythm