@k-l-lambda/lilylet 0.1.73 → 0.1.75

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.
@@ -71,13 +71,14 @@ export interface MusicXmlNote {
71
71
  isChord: boolean;
72
72
  isRest: boolean;
73
73
  isGrace: boolean;
74
+ isMeasureRest?: boolean;
74
75
  pitch?: MusicXmlPitch;
75
76
  duration: MusicXmlDuration;
76
77
  voice: number;
77
78
  staff?: number;
78
79
  stem?: MusicXmlStemDirection;
79
80
  notations?: MusicXmlNotations;
80
- fingering?: number;
81
+ fingerings?: number[];
81
82
  beams?: {
82
83
  type: 'begin' | 'continue' | 'end';
83
84
  number: number;
@@ -119,12 +120,21 @@ export interface MusicXmlDirection {
119
120
  octaveShift?: MusicXmlOctaveShift;
120
121
  coda?: boolean;
121
122
  segno?: boolean;
123
+ sound?: {
124
+ dacapo?: boolean;
125
+ dalsegno?: string;
126
+ segno?: string;
127
+ coda?: string;
128
+ tocoda?: string;
129
+ fine?: boolean;
130
+ };
122
131
  }
123
132
  export interface MusicXmlBarline {
124
133
  location?: 'left' | 'right' | 'middle';
125
134
  barStyle?: string;
126
135
  repeat?: {
127
136
  direction: 'forward' | 'backward';
137
+ times?: number;
128
138
  };
129
139
  ending?: {
130
140
  type: 'start' | 'stop' | 'discontinue';
@@ -5,6 +5,44 @@
5
5
  * Uses relative pitch mode matching the parser's behavior.
6
6
  */
7
7
  import { StemDirection, } from "./types.js";
8
+ import { parseMeasureLayout } from "./measureLayout.js";
9
+ // Walk a measure-layout AST, recording the FIRST measure index of each alternate
10
+ // ending (1st/2nd house) → its ending number, for the `% volta N` serializer
11
+ // comment convention. Only the alternates of a VoltaMLayout produce labels.
12
+ const collectVoltaLabels = (layout, out) => {
13
+ const firstIndexOf = (node) => {
14
+ switch (node.kind) {
15
+ case "single": return node.measure;
16
+ case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
17
+ case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
18
+ case "aba": return firstIndexOf(node.main);
19
+ }
20
+ };
21
+ const walk = (node) => {
22
+ switch (node.kind) {
23
+ case "block":
24
+ node.seq.forEach(walk);
25
+ break;
26
+ case "aba":
27
+ walk(node.main);
28
+ node.rest.forEach(walk);
29
+ break;
30
+ case "volta":
31
+ node.body.forEach(walk);
32
+ if (node.alternates) {
33
+ node.alternates.forEach((alt, ai) => {
34
+ const first = alt.length ? firstIndexOf(alt[0]) : undefined;
35
+ if (first !== undefined && !out.has(first))
36
+ out.set(first, ai + 1);
37
+ alt.forEach(walk);
38
+ });
39
+ }
40
+ break;
41
+ default: break;
42
+ }
43
+ };
44
+ walk(layout);
45
+ };
8
46
  const PHONETS = "cdefgab";
9
47
  /**
10
48
  * Calculate the octave markers needed to serialize a pitch in relative mode.
@@ -369,10 +407,20 @@ const serializeTupletEvent = (event, env) => {
369
407
  }
370
408
  else if (e.type === 'context') {
371
409
  const ctx = e;
410
+ // A context event inside a tuplet may carry several components at once
411
+ // (e.g. staff + clef). Emit each present one — previously only staff and
412
+ // stemDirection were handled, so a clef/ottava change inside a tuplet was
413
+ // silently dropped on serialization.
372
414
  if (ctx.staff != null) {
373
415
  parts.push(' \\staff "' + ctx.staff + '"');
374
416
  }
375
- else if (ctx.stemDirection != null) {
417
+ if (ctx.clef != null) {
418
+ parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
419
+ }
420
+ if (ctx.ottava != null) {
421
+ parts.push(' \\ottava #' + ctx.ottava);
422
+ }
423
+ if (ctx.stemDirection != null) {
376
424
  if (ctx.stemDirection === StemDirection.up)
377
425
  parts.push(' \\stemUp');
378
426
  else if (ctx.stemDirection === StemDirection.down)
@@ -511,6 +559,32 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
511
559
  // context{staff} events before this index are skipped in the main loop.
512
560
  let effectiveInitialStaff = voice.staff;
513
561
  let leadStaffScanEnd = 0;
562
+ // Staff of the first MUSICAL event (note/rest/...), honouring an explicit
563
+ // note.staff. If it differs from the home staff, the first inline staff switch
564
+ // would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
565
+ // binds home to the first `\staff` before any music), relocating the voice and
566
+ // any leading clef. We then force an explicit home-staff anchor up front. This
567
+ // scan is independent of leadStaffScanEnd because a leading clef/key stops that
568
+ // scan before the first note is reached.
569
+ let firstMusicalStaff = null;
570
+ {
571
+ let scanStaff = voice.staff;
572
+ for (const e of voice.events) {
573
+ if (e.type === 'context') {
574
+ const ctx = e;
575
+ if (ctx.staff != null)
576
+ scanStaff = ctx.staff;
577
+ continue;
578
+ }
579
+ if (e.type === 'pitchReset')
580
+ continue;
581
+ if (e.type === 'note')
582
+ firstMusicalStaff = e.staff ?? scanStaff;
583
+ else
584
+ firstMusicalStaff = scanStaff;
585
+ break;
586
+ }
587
+ }
514
588
  for (let i = 0; i < voice.events.length; i++) {
515
589
  const e = voice.events[i];
516
590
  if (e.type === 'pitchReset') {
@@ -538,10 +612,19 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
538
612
  // visible position; a staff switch after it must not be hoisted ahead of it.
539
613
  break;
540
614
  }
541
- // Output staff command if voice staff differs from current parser staff,
542
- // or always output if it's a grand staff score for clarity.
543
- // Use effectiveInitialStaff so carry-over replaces the default emission.
544
- if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
615
+ // Output staff command if the voice's initial staff is not what the PARSER will
616
+ // assume. The parser (lilylet.jison voice()) assigns every voice with no leading
617
+ // `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
618
+ // voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
619
+ // is not 1, even if the cross-measure carry (currentStaff) happens to match it
620
+ // (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
621
+ // this, the round-trip would silently relocate that voice, and its leading clef,
622
+ // to staff 1). Also emit when it differs from the carry (resets after grand staff)
623
+ // or always in a grand-staff part for clarity. Finally, force an anchor when the
624
+ // first note switches staff via note.staff (otherwise that inline `\staff` would
625
+ // be read as the leading staff and rebind the voice's home).
626
+ const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
627
+ if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
545
628
  parts.push('\\staff "' + effectiveInitialStaff + '"');
546
629
  }
547
630
  // Output key/time signatures after \staff (for first voice only)
@@ -568,7 +651,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
568
651
  // Prefer this voice's leading clef (a clef before any music); fall back to the
569
652
  // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
570
653
  // hoisted here — it is emitted inline at its position.
571
- const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
654
+ //
655
+ // The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
656
+ // the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
657
+ // with no clef of its own must only use the fallback to ESTABLISH a staff that has
658
+ // not been emitted yet — never to "restate" it once any sibling voice has already
659
+ // emitted/changed this staff's clef this measure, because the fallback would then be
660
+ // stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
661
+ // treble, then v2 with no clef must not re-emit the carried-in bass).
662
+ const ownClef = findVoiceClef(voice);
663
+ const carryClef = allStaffClefs?.[voice.staff];
664
+ const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
572
665
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
573
666
  if (voiceClef && !clefAlreadyEmitted) {
574
667
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
@@ -591,7 +684,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
591
684
  activeStaff = ctx.staff;
592
685
  parts.push('\\staff "' + activeStaff + '"');
593
686
  // Emit target staff clef if the event carries one or allStaffClefs knows it
594
- const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
687
+ const ctxClef = ctx.clef ||
688
+ (emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
595
689
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
596
690
  parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
597
691
  if (emittedClefs)
@@ -618,7 +712,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
618
712
  activeStaff = effectiveStaff;
619
713
  parts.push('\\staff "' + activeStaff + '"');
620
714
  // Emit the target staff's clef if it differs from what was last emitted for this staff
621
- const targetClef = allStaffClefs?.[activeStaff];
715
+ const targetClef = emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
622
716
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
623
717
  parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
624
718
  if (emittedClefs)
@@ -776,6 +870,9 @@ const serializeMetadata = (metadata) => {
776
870
  if (metadata.staves) {
777
871
  lines.push('[staves "' + escapeString(metadata.staves) + '"]');
778
872
  }
873
+ if (metadata.measureLayout) {
874
+ lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
875
+ }
779
876
  if (metadata.instruments) {
780
877
  for (const [key, instr] of Object.entries(metadata.instruments)) {
781
878
  let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
@@ -856,9 +953,26 @@ export const serializeLilyletDoc = (doc) => {
856
953
  measureStrs.push(measureStr || 's1');
857
954
  currentStaff = newStaff;
858
955
  }
859
- // Join measures with bar, measure number comment, and double newline
956
+ // Volta house-label comments (non-binding convention, like the `% n` measure
957
+ // index): if a measure-layout directive is present, mark the first measure of
958
+ // each alternate ending with a `% volta N` comment. Derived from the layout
959
+ // AST; purely cosmetic, never parsed back.
960
+ const voltaLabels = new Map(); // 1-based measure index → ending number
961
+ if (doc.metadata?.measureLayout) {
962
+ try {
963
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
964
+ collectVoltaLabels(layout, voltaLabels);
965
+ }
966
+ catch { /* ignore malformed layout */ }
967
+ }
968
+ // Join measures with bar, measure number comment, and double newline. A
969
+ // `% volta N` label (if any) is prepended on its own line before the measure.
860
970
  const measuresOutput = measureStrs
861
- .map((m, i) => m + ' | %' + (i + 1))
971
+ .map((m, i) => {
972
+ const label = voltaLabels.get(i + 1);
973
+ const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
974
+ return prefix + m + ' | %' + (i + 1);
975
+ })
862
976
  .join('\n\n');
863
977
  parts.push(measuresOutput);
864
978
  return parts.join('\n');
@@ -126,6 +126,7 @@ export interface Tie {
126
126
  export interface Slur {
127
127
  markType: 'slur';
128
128
  start: boolean;
129
+ number?: number;
129
130
  }
130
131
  export interface Beam {
131
132
  markType: 'beam';
@@ -176,6 +177,7 @@ export interface RestEvent {
176
177
  invisible?: boolean;
177
178
  fullMeasure?: boolean;
178
179
  pitch?: Pitch;
180
+ marks?: Mark[];
179
181
  }
180
182
  export interface ContextChange {
181
183
  type: 'context';
@@ -240,6 +242,7 @@ export interface Metadata {
240
242
  instrument?: string;
241
243
  genre?: string;
242
244
  staves?: string;
245
+ measureLayout?: string;
243
246
  instruments?: {
244
247
  [key: string]: InstrumentName;
245
248
  };
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayout.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayout.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayoutFromXml.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayoutFromXml.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
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",
@@ -34,16 +34,20 @@
34
34
  "prepublishOnly": "npm run build:grammar && npm run build",
35
35
  "test": "tsx ./tests/parser.ts",
36
36
  "test:mei": "tsx ./tests/mei.ts",
37
+ "test:mei-diff": "tsx ./tests/musicxml-mei-diff.ts",
37
38
  "test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
38
39
  "test:partial": "tsx ./tests/unit/partialWarning.test.ts",
39
40
  "test:decoder": "tsx ./tests/lilypondDecoder.ts",
40
41
  "test:abc": "tsx ./tests/abc-decoder.ts",
41
42
  "test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
43
+ "test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
42
44
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
43
45
  "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
44
46
  "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
45
47
  "build:browser-bundle": "esbuild tools/browserBundle.ts --bundle --format=iife --platform=browser --minify --outfile=dist/lilylet.bundle.js",
46
48
  "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
49
+ "test:measures": "tsx ./tests/unit/measureLayout.test.ts",
50
+ "test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
47
51
  "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",
48
52
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
49
53
  "ts": "tsx"
@@ -69,7 +73,7 @@
69
73
  "ts-node": "^10.9.2",
70
74
  "tsx": "^4.21.0",
71
75
  "typescript": "^5.3.3",
72
- "verovio": "^5.7.0",
76
+ "verovio": "^6.2.0",
73
77
  "yargs": "^17.7.2"
74
78
  },
75
79
  "dependencies": {