@k-l-lambda/lilylet 0.1.76 → 0.1.78

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.
@@ -4,7 +4,7 @@
4
4
  * Converts LilyletDoc to MusicXML format.
5
5
  * Produces valid MusicXML 4.0 partwise documents.
6
6
  */
7
- import { StemDirection, Accidental, HairpinType, PedalType, } from "./types.js";
7
+ import { StemDirection, Accidental, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
8
8
  import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
9
9
  import { parseStaffLayout } from "./staffLayout.js";
10
10
  // === Constants and Reverse Mappings ===
@@ -78,6 +78,7 @@ const DYNAMIC_TO_XML = {
78
78
  fff: 'fff',
79
79
  sfz: 'sfz',
80
80
  rfz: 'rfz',
81
+ fp: 'fp',
81
82
  };
82
83
  // Barline style to MusicXML
83
84
  const BARLINE_TO_XML = {
@@ -202,6 +203,7 @@ const encodeAttributes = (level, options) => {
202
203
  const encodeNotations = (marks, level) => {
203
204
  const articulations = [];
204
205
  const ornaments = [];
206
+ const technical = [];
205
207
  const otherNotations = [];
206
208
  for (const mark of marks) {
207
209
  switch (mark.markType) {
@@ -212,17 +214,25 @@ const encodeNotations = (marks, level) => {
212
214
  }
213
215
  break;
214
216
  case 'ornament':
215
- const ornXml = ORNAMENT_TO_XML[mark.type];
216
- if (ornXml) {
217
- if (mark.type === 'fermata') {
218
- otherNotations.push('<fermata/>');
219
- }
220
- else if (mark.type === 'arpeggio') {
221
- otherNotations.push('<arpeggiate/>');
222
- }
223
- else {
217
+ // fermata / arpeggio live at the <notations> level; breath-mark is an
218
+ // <articulations> child; the rest are true <ornaments>. (shortFermata is
219
+ // a fermata with an "angled" shape — decoder maps it back.)
220
+ if (mark.type === 'fermata') {
221
+ otherNotations.push('<fermata/>');
222
+ }
223
+ else if (mark.type === 'shortFermata') {
224
+ otherNotations.push('<fermata>angled</fermata>');
225
+ }
226
+ else if (mark.type === 'arpeggio') {
227
+ otherNotations.push('<arpeggiate/>');
228
+ }
229
+ else if (mark.type === 'breath') {
230
+ articulations.push('<breath-mark/>');
231
+ }
232
+ else {
233
+ const ornXml = ORNAMENT_TO_XML[mark.type];
234
+ if (ornXml)
224
235
  ornaments.push(`<${ornXml}/>`);
225
- }
226
236
  }
227
237
  break;
228
238
  case 'tie':
@@ -234,12 +244,18 @@ const encodeNotations = (marks, level) => {
234
244
  case 'tuplet':
235
245
  otherNotations.push(`<tuplet type="${mark.start ? 'start' : 'stop'}"/>`);
236
246
  break;
247
+ case 'glissando':
248
+ // Lilylet \glissando is start-only (the line auto-connects to the next
249
+ // note); emit just the start spanner so the decoder reads it back.
250
+ otherNotations.push('<glissando type="start" number="1"/>');
251
+ break;
237
252
  case 'fingering':
238
- // Fingering goes in technical
253
+ technical.push(`<fingering>${mark.finger}</fingering>`);
239
254
  break;
240
255
  }
241
256
  }
242
- if (articulations.length === 0 && ornaments.length === 0 && otherNotations.length === 0) {
257
+ if (articulations.length === 0 && ornaments.length === 0 &&
258
+ technical.length === 0 && otherNotations.length === 0) {
243
259
  return '';
244
260
  }
245
261
  let xml = `${indent(level)}<notations>\n`;
@@ -260,6 +276,13 @@ const encodeNotations = (marks, level) => {
260
276
  }
261
277
  xml += `${indent(level + 1)}</ornaments>\n`;
262
278
  }
279
+ if (technical.length > 0) {
280
+ xml += `${indent(level + 1)}<technical>\n`;
281
+ for (const tech of technical) {
282
+ xml += `${indent(level + 2)}${tech}\n`;
283
+ }
284
+ xml += `${indent(level + 1)}</technical>\n`;
285
+ }
263
286
  xml += `${indent(level)}</notations>\n`;
264
287
  return xml;
265
288
  };
@@ -369,8 +392,14 @@ const encodeRestWithTuplet = (event, voice, staff, level, isFirst, isLast) => {
369
392
  /**
370
393
  * Encode direction element (dynamics, tempo, etc.)
371
394
  */
372
- const encodeDirection = (marks, level) => {
395
+ const encodeDirection = (marks, level, staff = 0, emitStaff = false) => {
373
396
  let xml = '';
397
+ // In a grand-staff part, a <direction> must name its <staff> or the decoder
398
+ // can't route the mark back to the right staff (it would attach to the current
399
+ // voice/staff and drift — observed on chopin dynamics and cross-measure pedals).
400
+ const staffTag = (emitStaff && staff > 0)
401
+ ? `${indent(level + 1)}<staff>${staff}</staff>\n`
402
+ : '';
374
403
  for (const mark of marks) {
375
404
  if (mark.markType === 'dynamic') {
376
405
  const dynXml = DYNAMIC_TO_XML[mark.type];
@@ -381,6 +410,7 @@ const encodeDirection = (marks, level) => {
381
410
  xml += `${indent(level + 3)}<${dynXml}/>\n`;
382
411
  xml += `${indent(level + 2)}</dynamics>\n`;
383
412
  xml += `${indent(level + 1)}</direction-type>\n`;
413
+ xml += staffTag;
384
414
  xml += `${indent(level)}</direction>\n`;
385
415
  }
386
416
  }
@@ -400,6 +430,7 @@ const encodeDirection = (marks, level) => {
400
430
  xml += `${indent(level + 1)}<direction-type>\n`;
401
431
  xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
402
432
  xml += `${indent(level + 1)}</direction-type>\n`;
433
+ xml += staffTag;
403
434
  xml += `${indent(level)}</direction>\n`;
404
435
  }
405
436
  }
@@ -416,9 +447,31 @@ const encodeDirection = (marks, level) => {
416
447
  xml += `${indent(level + 1)}<direction-type>\n`;
417
448
  xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
418
449
  xml += `${indent(level + 1)}</direction-type>\n`;
450
+ xml += staffTag;
419
451
  xml += `${indent(level)}</direction>\n`;
420
452
  }
421
453
  }
454
+ else if (mark.markType === 'markup') {
455
+ // Text direction → <words>. The decoder reads any non-tempo <words> back
456
+ // as a markup mark (text-as-markup convention).
457
+ const placement = mark.placement ? ` placement="${mark.placement}"` : '';
458
+ xml += `${indent(level)}<direction${placement}>\n`;
459
+ xml += `${indent(level + 1)}<direction-type>\n`;
460
+ xml += `${indent(level + 2)}<words>${escapeXml(mark.content)}</words>\n`;
461
+ xml += `${indent(level + 1)}</direction-type>\n`;
462
+ xml += staffTag;
463
+ xml += `${indent(level)}</direction>\n`;
464
+ }
465
+ else if (mark.markType === 'navigation') {
466
+ // \coda / \segno glyph → <direction-type><coda|segno>.
467
+ const glyph = mark.type === NavigationMarkType.coda ? 'coda' : 'segno';
468
+ xml += `${indent(level)}<direction>\n`;
469
+ xml += `${indent(level + 1)}<direction-type>\n`;
470
+ xml += `${indent(level + 2)}<${glyph}/>\n`;
471
+ xml += `${indent(level + 1)}</direction-type>\n`;
472
+ xml += staffTag;
473
+ xml += `${indent(level)}</direction>\n`;
474
+ }
422
475
  }
423
476
  return xml;
424
477
  };
@@ -536,20 +589,26 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
536
589
  for (const event of voice.events) {
537
590
  switch (event.type) {
538
591
  case 'note': {
539
- // Check for direction marks (dynamics, hairpins, pedals)
540
- const directionMarks = event.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal') || [];
592
+ // Check for direction marks (dynamics, hairpins, pedals, text, nav)
593
+ const directionMarks = event.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
594
+ m.markType === 'pedal' || m.markType === 'markup' ||
595
+ m.markType === 'navigation') || [];
541
596
  if (directionMarks.length > 0) {
542
- xml += encodeDirection(directionMarks, level + 1);
597
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
543
598
  }
544
599
  // Encode main note
545
600
  xml += encodeNote(event, voiceNum, currentStaff, level + 1);
546
601
  const dur = calculateDuration(event.duration);
547
602
  voicePosition += dur;
548
- // Encode chord notes
603
+ // Encode chord notes. Marks live on the parent NoteEvent (the whole
604
+ // chord) and are emitted on the first note only — re-emitting them on
605
+ // each member would multiply them on decode (the decoder aggregates
606
+ // every member's marks back onto the one chord event).
549
607
  for (let i = 1; i < event.pitches.length; i++) {
550
608
  const chordEvent = {
551
609
  ...event,
552
610
  pitches: [event.pitches[i]],
611
+ marks: undefined,
553
612
  };
554
613
  xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
555
614
  }
@@ -582,6 +641,14 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
582
641
  const isFirst = ti === 0;
583
642
  const isLast = ti === tupletEvents.length - 1;
584
643
  if (subEvent.type === 'note') {
644
+ // Direction marks (dynamics, hairpins, pedals, text, nav) — same
645
+ // as the plain-note case; without this they vanish inside tuplets.
646
+ const directionMarks = subEvent.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
647
+ m.markType === 'pedal' || m.markType === 'markup' ||
648
+ m.markType === 'navigation') || [];
649
+ if (directionMarks.length > 0) {
650
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
651
+ }
585
652
  // Add tuplet notation marks
586
653
  const tupletMarks = [];
587
654
  if (isFirst)
@@ -56,7 +56,7 @@ export interface MusicXmlNotations {
56
56
  }[];
57
57
  articulations?: string[];
58
58
  ornaments?: string[];
59
- fermata?: boolean;
59
+ fermata?: 'normal' | 'short';
60
60
  arpeggiate?: boolean;
61
61
  tremolo?: {
62
62
  type: 'single' | 'start' | 'stop';
@@ -66,6 +66,7 @@ export interface MusicXmlNotations {
66
66
  type: 'start' | 'stop';
67
67
  number: number;
68
68
  };
69
+ glissando?: boolean;
69
70
  }
70
71
  export interface MusicXmlNote {
71
72
  isChord: boolean;
@@ -117,6 +118,7 @@ export interface MusicXmlDirection {
117
118
  pedal?: MusicXmlPedal;
118
119
  metronome?: MusicXmlMetronome;
119
120
  words?: MusicXmlWords[];
121
+ rehearsal?: string;
120
122
  octaveShift?: MusicXmlOctaveShift;
121
123
  coda?: boolean;
122
124
  segno?: boolean;
@@ -339,7 +339,7 @@ const DYNAMIC_MAP = {
339
339
  fz: DynamicType.sfz,
340
340
  sfp: DynamicType.sfz,
341
341
  sfpp: DynamicType.sfz,
342
- fp: DynamicType.f, // forte-piano, approximate
342
+ fp: DynamicType.fp, // forte-piano
343
343
  };
344
344
  export const convertDynamic = (name) => {
345
345
  return DYNAMIC_MAP[name.toLowerCase()];
@@ -114,6 +114,7 @@ const ORNAMENT_MAP = {
114
114
  fermata: '\\fermata',
115
115
  shortFermata: '\\shortfermata',
116
116
  arpeggio: '\\arpeggio',
117
+ breath: '\\breathe',
117
118
  };
118
119
  // Dynamic to Lilylet notation
119
120
  const DYNAMIC_MAP = {
@@ -232,6 +233,19 @@ const serializeMarks = (marks) => {
232
233
  case 'fingering':
233
234
  parts.push('-' + mark.finger);
234
235
  break;
236
+ case 'markup': {
237
+ // Text direction attached to a note (e.g. ^\markup "cantabile").
238
+ // Placement: above → ^, below → _, unspecified → - (default).
239
+ const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
240
+ parts.push(prefix + '\\markup "' + escapeString(mark.content) + '"');
241
+ break;
242
+ }
243
+ case 'navigation':
244
+ parts.push(mark.type === 'coda' ? '\\coda' : '\\segno');
245
+ break;
246
+ case 'glissando':
247
+ parts.push('\\glissando');
248
+ break;
235
249
  }
236
250
  }
237
251
  return parts.join('');
@@ -39,7 +39,8 @@ export declare enum OrnamentType {
39
39
  prall = "prall",
40
40
  fermata = "fermata",
41
41
  shortFermata = "shortFermata",
42
- arpeggio = "arpeggio"
42
+ arpeggio = "arpeggio",
43
+ breath = "breath"
43
44
  }
44
45
  export declare enum DynamicType {
45
46
  ppp = "ppp",
@@ -150,7 +151,10 @@ export interface MarkupMark {
150
151
  content: string;
151
152
  placement?: Placement;
152
153
  }
153
- export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark;
154
+ export interface Glissando {
155
+ markType: 'glissando';
156
+ }
157
+ export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark | Glissando;
154
158
  export interface KeySignature {
155
159
  pitch: Phonet;
156
160
  accidental?: Accidental;
@@ -47,6 +47,7 @@ export var OrnamentType;
47
47
  OrnamentType["fermata"] = "fermata";
48
48
  OrnamentType["shortFermata"] = "shortFermata";
49
49
  OrnamentType["arpeggio"] = "arpeggio";
50
+ OrnamentType["breath"] = "breath";
50
51
  })(OrnamentType || (OrnamentType = {}));
51
52
  export var DynamicType;
52
53
  (function (DynamicType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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",
@@ -41,6 +41,7 @@
41
41
  "test:abc": "tsx ./tests/abc-decoder.ts",
42
42
  "test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
43
43
  "test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
44
+ "test:xml-roundtrip": "tsx ./tests/musicxml-roundtrip.ts",
44
45
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
45
46
  "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
46
47
  "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
@@ -48,6 +49,7 @@
48
49
  "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
49
50
  "test:measures": "tsx ./tests/unit/measureLayout.test.ts",
50
51
  "test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
52
+ "test:abc-repeats": "tsx ./tests/unit/abcRepeatLayout.test.ts",
51
53
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
52
54
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
53
55
  "ts": "tsx"
@@ -542,15 +542,18 @@ bar
542
542
  | ':' '|' ']' -> ':|]'
543
543
  | '|' N -> '|' + $2
544
544
  | '|' N volta_rest -> '|' + $2 + $3
545
- | ':' '|' N -> ':|' + $2
546
- | ':' '|' N volta_rest -> ':|' + $2 + $3
545
+ | ':' '|' N -> ':|' + $3
546
+ | ':' '|' N volta_rest -> ':|' + $3 + $4
547
547
  | '&' -> '&'
548
548
  ;
549
549
 
550
- // Additional volta endings after the first number, comma-separated: "1,2", "1,2,3".
550
+ // Additional volta endings after the first number: comma-separated "1,2,3", or a
551
+ // hyphen range "1-3". Mirrors abc2xml, which normalizes "[1-2" to number="1,2".
551
552
  volta_rest
552
553
  : ',' N -> ',' + $2
554
+ | '-' N -> ',' + $2
553
555
  | volta_rest ',' N -> $1 + ',' + $3
556
+ | volta_rest '-' N -> $1 + ',' + $3
554
557
  ;
555
558
 
556
559
  music
@@ -566,7 +569,8 @@ music
566
569
  | music NAME -> $1
567
570
  | music '^' NAME -> $1
568
571
  | music '^' articulation_letter -> $1
569
- | music '[' N -> $1
572
+ | music '[' N -> $1 ? [...$1, {volta: String($3)}] : [{volta: String($3)}]
573
+ | music '[' N volta_rest -> $1 ? [...$1, {volta: String($3) + $4}] : [{volta: String($3) + $4}]
570
574
  ;
571
575
 
572
576
  // Articulation-class letters (P macro: HJLMOPRSTuv). After a stray '^' inside a