@k-l-lambda/lilylet 0.1.63 → 0.1.65

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.
Files changed (104) hide show
  1. package/lib/lilylet/grammar.jison.js +1 -10
  2. package/lib/lilylet/meiEncoder.js +58 -40
  3. package/lib/source/abc/abc.d.ts +102 -0
  4. package/lib/source/abc/abc.js +25 -0
  5. package/lib/source/abc/parser.d.ts +3 -0
  6. package/lib/source/abc/parser.js +6 -0
  7. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  8. package/lib/source/lilylet/abcDecoder.js +1035 -0
  9. package/lib/source/lilylet/index.d.ts +10 -0
  10. package/lib/source/lilylet/index.js +10 -0
  11. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  12. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  13. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  14. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  15. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  16. package/lib/source/lilylet/meiEncoder.js +1985 -0
  17. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  18. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  19. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  20. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  21. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  22. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  23. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  24. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  25. package/lib/source/lilylet/parser.d.ts +14 -0
  26. package/lib/source/lilylet/parser.js +161 -0
  27. package/lib/source/lilylet/serializer.d.ts +11 -0
  28. package/lib/source/lilylet/serializer.js +791 -0
  29. package/lib/source/lilylet/types.d.ts +253 -0
  30. package/lib/source/lilylet/types.js +100 -0
  31. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  32. package/lib/tests/abc-abcjs-parse.js +90 -0
  33. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  34. package/lib/tests/abc-abcjs-svg.js +143 -0
  35. package/lib/tests/abc-decoder.d.ts +1 -0
  36. package/lib/tests/abc-decoder.js +67 -0
  37. package/lib/tests/abc-mei-compare.d.ts +1 -0
  38. package/lib/tests/abc-mei-compare.js +525 -0
  39. package/lib/tests/auto-beam.d.ts +9 -0
  40. package/lib/tests/auto-beam.js +151 -0
  41. package/lib/tests/computeMeiHashes.d.ts +1 -0
  42. package/lib/tests/computeMeiHashes.js +87 -0
  43. package/lib/tests/encoder-mutation.d.ts +9 -0
  44. package/lib/tests/encoder-mutation.js +110 -0
  45. package/lib/tests/gpt-review-issues.d.ts +5 -0
  46. package/lib/tests/gpt-review-issues.js +255 -0
  47. package/lib/tests/json-to-lyl.d.ts +1 -0
  48. package/lib/tests/json-to-lyl.js +18 -0
  49. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  50. package/lib/tests/lilypond-roundtrip.js +558 -0
  51. package/lib/tests/lilypondDecoder.d.ts +6 -0
  52. package/lib/tests/lilypondDecoder.js +95 -0
  53. package/lib/tests/ly-to-lyl.d.ts +1 -0
  54. package/lib/tests/ly-to-lyl.js +12 -0
  55. package/lib/tests/mei.d.ts +1 -0
  56. package/lib/tests/mei.js +278 -0
  57. package/lib/tests/musicxml-decoder.d.ts +4 -0
  58. package/lib/tests/musicxml-decoder.js +61 -0
  59. package/lib/tests/musicxml-detail.d.ts +4 -0
  60. package/lib/tests/musicxml-detail.js +85 -0
  61. package/lib/tests/musicxml-fprod.d.ts +9 -0
  62. package/lib/tests/musicxml-fprod.js +153 -0
  63. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  64. package/lib/tests/musicxml-roundtrip.js +296 -0
  65. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  66. package/lib/tests/musicxml-to-mei.js +115 -0
  67. package/lib/tests/parser.d.ts +1 -0
  68. package/lib/tests/parser.js +17 -0
  69. package/lib/tests/render-k283.d.ts +1 -0
  70. package/lib/tests/render-k283.js +33 -0
  71. package/lib/tests/render-lyl.d.ts +1 -0
  72. package/lib/tests/render-lyl.js +35 -0
  73. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  74. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  75. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  76. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  77. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  78. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  79. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  80. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  81. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  82. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  83. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  84. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  85. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  86. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  87. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  88. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  89. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  90. package/lib/tests/unit/partialWarning.test.js +65 -0
  91. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  92. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  93. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  94. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  95. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  96. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  97. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  98. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  99. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  100. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  101. package/package.json +1 -1
  102. package/source/lilylet/grammar.jison.js +1 -10
  103. package/source/lilylet/lilylet.jison +1 -10
  104. package/source/lilylet/meiEncoder.ts +65 -40
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Unit test: \tuplet N/M D { notes } with base-duration argument must
3
+ * preserve the tuplet wrapper in the decoded doc.
4
+ *
5
+ * Bug: The LilyPond decoder accessed args[1].body to find the tuplet body,
6
+ * but \tuplet 3/2 4 { ... } has args = ["3/2", "4", {body}].
7
+ * args[1] is "4" (the base duration), not the music block, so body was
8
+ * empty and all notes were silently decoded as plain notes outside any tuplet.
9
+ *
10
+ * Real-world case: chopin-28-14.ly — every measure is wrapped in
11
+ * \tuplet 3/2 4 { | notes }
12
+ * producing 12 eighth notes in the time of 8, but the decoded lyl had
13
+ * no \times wrapper at all.
14
+ *
15
+ * Usage: npx tsx tests/unit/tupletWithBaseDuration.test.ts
16
+ */
17
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Unit test: \tuplet N/M D { notes } with base-duration argument must
3
+ * preserve the tuplet wrapper in the decoded doc.
4
+ *
5
+ * Bug: The LilyPond decoder accessed args[1].body to find the tuplet body,
6
+ * but \tuplet 3/2 4 { ... } has args = ["3/2", "4", {body}].
7
+ * args[1] is "4" (the base duration), not the music block, so body was
8
+ * empty and all notes were silently decoded as plain notes outside any tuplet.
9
+ *
10
+ * Real-world case: chopin-28-14.ly — every measure is wrapped in
11
+ * \tuplet 3/2 4 { | notes }
12
+ * producing 12 eighth notes in the time of 8, but the decoded lyl had
13
+ * no \times wrapper at all.
14
+ *
15
+ * Usage: npx tsx tests/unit/tupletWithBaseDuration.test.ts
16
+ */
17
+ import { decode } from "../../source/lilylet/lilypondDecoder.js";
18
+ import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
19
+ let passed = 0;
20
+ let failed = 0;
21
+ function assert(condition, message) {
22
+ if (condition) {
23
+ console.log(` ✓ ${message}`);
24
+ passed++;
25
+ }
26
+ else {
27
+ console.error(` ✗ FAIL: ${message}`);
28
+ failed++;
29
+ }
30
+ }
31
+ const LY_BOILERPLATE = `
32
+ \\version "2.22.0"
33
+ \\language "english"
34
+ \\header { tagline = ##f }
35
+ \\layout { \\context { \\Score autoBeaming = ##f } }
36
+ `;
37
+ // Warm-up
38
+ {
39
+ const w = console.warn, a = console.assert;
40
+ console.warn = () => { };
41
+ console.assert = () => { };
42
+ try {
43
+ await decode('{ c }');
44
+ }
45
+ catch { }
46
+ console.warn = w;
47
+ console.assert = a;
48
+ }
49
+ // ─── Test 1: \tuplet 3/2 4 { notes } preserves tuplet ─────────────────────
50
+ // Chopin 28-14 pattern: \tuplet 3/2 4 wraps 12 eighth notes per measure.
51
+ // Without base-duration: \tuplet 3/2 { ef8 bf gf ... } = 12 notes
52
+ // With base-duration: \tuplet 3/2 4 { | ef8 bf gf ... } = same semantics
53
+ console.log('\nTest 1: \\tuplet 3/2 4 { notes } — tuplet wrapper preserved');
54
+ await (async () => {
55
+ const LY = LY_BOILERPLATE + `
56
+ \\score {
57
+ \\new Staff {
58
+ \\new Voice {
59
+ \\relative ef, {
60
+ \\time 4/4
61
+ \\tuplet 3/2 4 {
62
+ ef8 [ bf' gf ] cf [ ef, cf' ] d, [ cf' f, ] bf [ d, bf']
63
+ }
64
+ }
65
+ }
66
+ }
67
+ \\layout {}
68
+ }
69
+ `;
70
+ const doc = await decode(LY);
71
+ assert(doc.measures.length >= 1, `decoded 1 measure (got ${doc.measures.length})`);
72
+ if (!doc.measures[0])
73
+ return;
74
+ const voice = doc.measures[0].parts[0]?.voices[0];
75
+ assert(!!voice, 'voice exists');
76
+ if (!voice)
77
+ return;
78
+ // The voice should have a tuplet event, NOT 12 bare note events
79
+ const tuplets = voice.events.filter(e => e.type === 'tuplet' || e.type === 'times');
80
+ const bareNotes = voice.events.filter(e => e.type === 'note');
81
+ assert(tuplets.length >= 1, `voice contains at least 1 tuplet/times event (got ${tuplets.length}) — not bare notes`);
82
+ assert(bareNotes.length === 0, `no bare notes at top level (got ${bareNotes.length}) — all should be inside tuplet`);
83
+ if (tuplets.length >= 1) {
84
+ const t = tuplets[0];
85
+ const innerNotes = t.events.filter(e => e.type === 'note');
86
+ assert(innerNotes.length === 12, `tuplet contains 12 inner notes (got ${innerNotes.length})`);
87
+ assert(t.ratio.numerator === 2 && t.ratio.denominator === 3, `ratio is 2/3 (lilylet: play 12 eighth notes in time of 8) — got ${t.ratio.numerator}/${t.ratio.denominator}`);
88
+ }
89
+ })();
90
+ // ─── Test 2: \tuplet 3/2 (no base duration) — should still work ────────────
91
+ console.log('\nTest 2: \\tuplet 3/2 { notes } without base duration (baseline)');
92
+ await (async () => {
93
+ const LY = LY_BOILERPLATE + `
94
+ \\score {
95
+ \\new Staff {
96
+ \\new Voice {
97
+ \\relative ef, {
98
+ \\time 4/4
99
+ \\tuplet 3/2 {
100
+ ef8 [ bf' gf ] cf [ ef, cf' ] d, [ cf' f, ] bf [ d, bf']
101
+ }
102
+ }
103
+ }
104
+ }
105
+ \\layout {}
106
+ }
107
+ `;
108
+ const doc = await decode(LY);
109
+ const voice = doc.measures[0]?.parts[0]?.voices[0];
110
+ const tuplets = voice?.events.filter(e => e.type === 'tuplet' || e.type === 'times') ?? [];
111
+ assert(tuplets.length >= 1, `\\tuplet 3/2 (no base dur) also produces tuplet event (got ${tuplets.length})`);
112
+ })();
113
+ // ─── Test 3: serialized lyl contains \tuplet or \times wrapper ─────────────
114
+ console.log('\nTest 3: serialized lyl contains tuplet notation');
115
+ await (async () => {
116
+ const LY = LY_BOILERPLATE + `
117
+ \\score {
118
+ \\new Staff {
119
+ \\new Voice {
120
+ \\relative ef, {
121
+ \\time 4/4
122
+ \\tuplet 3/2 4 {
123
+ ef8 [ bf' gf ] cf [ ef, cf' ] d, [ cf' f, ] bf [ d, bf']
124
+ }
125
+ }
126
+ }
127
+ }
128
+ \\layout {}
129
+ }
130
+ `;
131
+ const doc = await decode(LY);
132
+ const lyl = serializeLilyletDoc(doc);
133
+ console.log(` lyl: ${lyl.trim().split('\n').find(l => l.includes('time') || l.includes('tuplet') || l.includes('times')) ?? '(not found)'}`);
134
+ assert(lyl.includes('\\tuplet') || lyl.includes('\\times'), `serialized lyl contains \\tuplet or \\times notation (got no tuplet wrapper)`);
135
+ })();
136
+ // ─── summary ─────────────────────────────────────────────────────────────────
137
+ console.log(`\n${'═'.repeat(50)}`);
138
+ console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
139
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Unit tests for voice.staff parsing edge cases from GPT review of the
3
+ * leadingStaff fix (commit c0763a4, 47fcf61).
4
+ *
5
+ * Cases:
6
+ * A. non-staff context before \staff "N" in same voice line
7
+ * B. second voice after \\ with no explicit \staff (fallback)
8
+ * C. trailing | + newline → spurious empty measure filtered
9
+ * D. measure with only \bar → filtered as empty
10
+ *
11
+ * Usage: npx tsx tests/unit/voiceStaffParsing.test.ts
12
+ */
13
+ export {};
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Unit tests for voice.staff parsing edge cases from GPT review of the
3
+ * leadingStaff fix (commit c0763a4, 47fcf61).
4
+ *
5
+ * Cases:
6
+ * A. non-staff context before \staff "N" in same voice line
7
+ * B. second voice after \\ with no explicit \staff (fallback)
8
+ * C. trailing | + newline → spurious empty measure filtered
9
+ * D. measure with only \bar → filtered as empty
10
+ *
11
+ * Usage: npx tsx tests/unit/voiceStaffParsing.test.ts
12
+ */
13
+ import { parseCode } from '../../source/lilylet/parser.js';
14
+ let passed = 0;
15
+ let failed = 0;
16
+ function assert(condition, message) {
17
+ if (condition) {
18
+ console.log(` ✓ ${message}`);
19
+ passed++;
20
+ }
21
+ else {
22
+ console.error(` ✗ FAIL: ${message}`);
23
+ failed++;
24
+ }
25
+ }
26
+ function voiceStaff(lyl, measureIdx = 0, voiceIdx = 0) {
27
+ const doc = parseCode(lyl);
28
+ return doc.measures[measureIdx]?.parts[0]?.voices[voiceIdx]?.staff ?? -1;
29
+ }
30
+ function measureCount(lyl) {
31
+ return parseCode(lyl).measures.length;
32
+ }
33
+ // ─── Case A1: \clef before \staff "2" ────────────────────────────────────────
34
+ console.log('\nCase A1: \\clef before \\staff "2" — staff must be 2');
35
+ {
36
+ const lyl = `\\clef "bass" \\staff "2" c4 d e f |`;
37
+ assert(voiceStaff(lyl) === 2, `\\clef "bass" \\staff "2" c4 → voice.staff=2 (got ${voiceStaff(lyl)})`);
38
+ }
39
+ // ─── Case A2: \key before \staff "2" ─────────────────────────────────────────
40
+ console.log('\nCase A2: \\key before \\staff "2" — staff must be 2');
41
+ {
42
+ const lyl = `\\key g \\major \\staff "2" c4 d e f |`;
43
+ assert(voiceStaff(lyl) === 2, `\\key g \\major \\staff "2" → voice.staff=2 (got ${voiceStaff(lyl)})`);
44
+ }
45
+ // ─── Case A3: \time \clef \key before \staff "2" \times ──────────────────────
46
+ console.log('\nCase A3: multiple context events before \\staff "2" \\times');
47
+ {
48
+ const lyl = `\\time 4/4 \\clef "treble" \\key c \\major \\staff "2" \\times 2/3 { c8 d e } r2. |`;
49
+ const doc = parseCode(lyl);
50
+ const voice = doc.measures[0]?.parts[0]?.voices[0];
51
+ assert(!!voice, 'voice exists');
52
+ if (voice) {
53
+ assert(voice.staff === 2, `context chain before \\staff "2" → voice.staff=2 (got ${voice.staff})`);
54
+ // Verify the event stream: leading context events (non-staff) then staff=2 context before tuplet
55
+ const staffCtxIdx = voice.events.findIndex((e) => e.type === 'context' && e.staff === 2);
56
+ const tupletIdx = voice.events.findIndex((e) => e.type === 'tuplet' || e.type === 'times');
57
+ assert(staffCtxIdx >= 0, `context{staff:2} event exists in events`);
58
+ assert(tupletIdx >= 0, `tuplet event exists in events`);
59
+ assert(staffCtxIdx < tupletIdx, `context{staff:2} (idx=${staffCtxIdx}) appears before tuplet (idx=${tupletIdx})`);
60
+ }
61
+ }
62
+ // ─── Case A4: \staff "1" first, cross-staff switch — staff must be 1 ─────────
63
+ console.log('\nCase A4: \\staff "1" then \\staff "2" — leading staff is 1');
64
+ {
65
+ const lyl = `\\staff "1" c4 d \\staff "2" e f |`;
66
+ assert(voiceStaff(lyl) === 1, `\\staff "1" c4 d \\staff "2" e f → voice.staff=1 (got ${voiceStaff(lyl)})`);
67
+ }
68
+ // ─── Case B1: second voice — stale currentStaff=2 must NOT leak ──────────────
69
+ // Voice 0 ends after switching to \staff "2" (currentStaff=2 is stale).
70
+ // Voice 1 has no explicit \staff — should default to staff=1 (part_start reset),
71
+ // NOT inherit the stale currentStaff=2 from voice 0.
72
+ console.log('\nCase B1: second voice (no \\staff) must NOT inherit stale currentStaff=2');
73
+ {
74
+ const lyl = `\\staff "1" c4 \\staff "2" d e f \\\\ g4 a b c |`;
75
+ const doc = parseCode(lyl);
76
+ const voices = doc.measures[0]?.parts[0]?.voices ?? [];
77
+ assert(voices.length === 2, `two voices parsed (got ${voices.length})`);
78
+ assert(voices[0].staff === 1, `voice 0 leading \\staff "1" (got ${voices[0]?.staff})`);
79
+ // part_start resets currentStaff=1 for each part_voices attempt;
80
+ // if stale currentStaff=2 leaked, voice 1 would wrongly get staff=2
81
+ assert(voices[1].staff === 1, `voice 1 no \\staff → defaults to 1, not stale 2 (got ${voices[1]?.staff})`);
82
+ }
83
+ // ─── Case B2: \\ then \staff "2" ─────────────────────────────────────────────
84
+ console.log('\nCase B2: second voice starts with \\staff "2"');
85
+ {
86
+ const lyl = `\\staff "1" c4 d e f \\\\ \\staff "2" c4 d e f |`;
87
+ const doc = parseCode(lyl);
88
+ const voices = doc.measures[0]?.parts[0]?.voices ?? [];
89
+ assert(voices.length === 2, `two voices parsed (got ${voices.length})`);
90
+ assert(voices[1].staff === 2, `voice 1 \\staff "2" → staff=2 (got ${voices[1]?.staff})`);
91
+ }
92
+ // ─── Case C: trailing newline after last | — no spurious measure ─────────────
93
+ console.log('\nCase C: trailing newline after last | — no spurious empty measure');
94
+ {
95
+ // lyl with explicit trailing newline
96
+ const lyl = `\\staff "1" c4 d e f |\n`;
97
+ const count = measureCount(lyl);
98
+ assert(count === 1, `exactly 1 measure (got ${count})`);
99
+ }
100
+ // ─── Case D: measure with only \bar — filtered as empty ──────────────────────
101
+ console.log('\nCase D: measure with only \\bar "|." — filtered as empty');
102
+ {
103
+ const lyl = `\\staff "1" c4 d e f | \\bar "|." |`;
104
+ const count = measureCount(lyl);
105
+ // The real measure (c4 d e f) is kept; the barline-only measure is filtered
106
+ assert(count === 1, `barline-only measure filtered, 1 real measure remains (got ${count})`);
107
+ }
108
+ // ─── Case E: measure with notes then trailing empty measure ──────────────────
109
+ console.log('\nCase E: two real measures, no spurious third');
110
+ {
111
+ const lyl = `\\staff "1" c4 d e f | g4 a b c |\n`;
112
+ const count = measureCount(lyl);
113
+ assert(count === 2, `exactly 2 measures (got ${count})`);
114
+ }
115
+ // ─── Summary ─────────────────────────────────────────────────────────────────
116
+ console.log(`\n${'═'.repeat(50)}`);
117
+ console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
118
+ process.exit(failed > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
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",
@@ -205,16 +205,7 @@ case 44:
205
205
  this.$ = markupEvent($$[$0].slice(1, -1));
206
206
  break;
207
207
  case 45:
208
-
209
- // On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
210
- if (currentOttava !== 0) {
211
- const ottavaReset = contextChange({ ottava: 0 });
212
- currentOttava = 0;
213
- this.$ = [ottavaReset, { type: 'pitchReset' }];
214
- } else {
215
- this.$ = { type: 'pitchReset' };
216
- }
217
-
208
+ this.$ = { type: 'pitchReset' };
218
209
  break;
219
210
  case 46: case 47:
220
211
  currentDuration = $$[$0-1]; this.$ = noteEvent($$[$0-2], $$[$0-1], $$[$0]);
@@ -460,16 +460,7 @@ markup_event
460
460
  ;
461
461
 
462
462
  pitch_reset_event
463
- : NEWLINE %{
464
- // On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
465
- if (currentOttava !== 0) {
466
- const ottavaReset = contextChange({ ottava: 0 });
467
- currentOttava = 0;
468
- $$ = [ottavaReset, { type: 'pitchReset' }];
469
- } else {
470
- $$ = { type: 'pitchReset' };
471
- }
472
- %}
463
+ : NEWLINE -> { type: 'pitchReset' }
473
464
  ;
474
465
 
475
466
  note_event
@@ -927,6 +927,10 @@ interface PendingOctave {
927
927
  disPlace: 'above' | 'below';
928
928
  startId: string;
929
929
  shift: number; // The ottava value (1, -1, 2, -2)
930
+ continued?: boolean;
931
+ emitted?: boolean;
932
+ endToken?: string;
933
+ endFallbackId?: string;
930
934
  }
931
935
  type OttavaState = Record<string, PendingOctave | null>; // voice key -> pending octave span
932
936
 
@@ -956,6 +960,7 @@ interface LayerResult {
956
960
  endingClef?: Clef; // For cross-measure clef tracking
957
961
  lastNoteId: string | null; // For cross-measure ottava span end tracking
958
962
  currentOttavaShift: number; // Current ottava shift for pitch encoding
963
+ octaveEndReplacements: Record<string, string>;
959
964
  }
960
965
 
961
966
 
@@ -1006,8 +1011,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1006
1011
 
1007
1012
  // Track octave spans - initialize from previous measure if continuing
1008
1013
  const octaves: OctaveSpan[] = [];
1009
- let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
1010
- initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
1014
+ const octaveEndReplacements: Record<string, string> = {};
1015
+ let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string; continued?: boolean; emitted?: boolean; endToken?: string; endFallbackId?: string } | null =
1016
+ initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId, continued: initialOctave.continued, emitted: initialOctave.emitted, endToken: initialOctave.endToken, endFallbackId: initialOctave.endFallbackId } : null;
1011
1017
  let pendingOttava: number | null = null; // Track ottava to apply to next note
1012
1018
  let currentOttavaShift: number = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
1013
1019
  let lastNoteId: string | null = null; // Track last note id for ending ottava spans
@@ -1115,6 +1121,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1115
1121
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
1116
1122
  xml += result.xml;
1117
1123
  lastNoteId = result.elementId;
1124
+ if (currentOctave?.endToken) {
1125
+ octaveEndReplacements[currentOctave.endToken] = result.elementId;
1126
+ }
1118
1127
 
1119
1128
  // Flush any pending markups onto this note
1120
1129
  flushPendingMarkups(result.elementId);
@@ -1125,9 +1134,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1125
1134
  const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
1126
1135
  // Close existing span first if it has a different value
1127
1136
  if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
1128
- // Different value - close the old span
1129
- // Use the lastNoteId from before this note (which we saved before processing)
1130
- // Note: The span from previous measure will be closed by encodeMeasure
1137
+ if (lastNoteId) {
1138
+ octaves.push({
1139
+ dis: currentOctave.dis,
1140
+ disPlace: currentOctave.disPlace,
1141
+ startId: currentOctave.startId,
1142
+ endId: lastNoteId,
1143
+ });
1144
+ }
1131
1145
  currentOctave = null;
1132
1146
  }
1133
1147
  // Start new span if we don't already have one with the same value
@@ -1135,6 +1149,11 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1135
1149
  currentOctave = { dis, disPlace, startId: result.elementId };
1136
1150
  }
1137
1151
  pendingOttava = null;
1152
+ } else if (currentOctave?.continued) {
1153
+ if (!currentOctave.endToken) {
1154
+ currentOctave.startId = result.elementId;
1155
+ }
1156
+ currentOctave.continued = false;
1138
1157
  }
1139
1158
 
1140
1159
  // Update pending tie pitches
@@ -1290,12 +1309,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1290
1309
  if (ctx.ottava === 0) {
1291
1310
  // End current ottava span
1292
1311
  if (currentOctave && lastNoteId) {
1293
- octaves.push({
1294
- dis: currentOctave.dis,
1295
- disPlace: currentOctave.disPlace,
1296
- startId: currentOctave.startId,
1297
- endId: lastNoteId,
1298
- });
1312
+ if (!currentOctave.emitted) {
1313
+ octaves.push({
1314
+ dis: currentOctave.dis,
1315
+ disPlace: currentOctave.disPlace,
1316
+ startId: currentOctave.startId,
1317
+ endId: lastNoteId,
1318
+ });
1319
+ }
1299
1320
  currentOctave = null;
1300
1321
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1301
1322
  }
@@ -1307,7 +1328,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1307
1328
  const dis: 8 | 15 = Math.abs(ctx.ottava) === 2 ? 15 : 8;
1308
1329
  const disPlace: 'above' | 'below' = ctx.ottava > 0 ? 'above' : 'below';
1309
1330
  if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
1310
- // Continuation - restore the shift but don't change the span
1331
+ // Continuation - restore the shift and let the existing 8va line reach this measure's first note
1311
1332
  currentOttavaShift = ctx.ottava;
1312
1333
  } else {
1313
1334
  // Different value - start new ottava span (will be applied to next note)
@@ -1383,14 +1404,25 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1383
1404
  xml += `${baseIndent}</beam>\n`;
1384
1405
  }
1385
1406
 
1386
- // Don't close ottava span at measure end - it may continue in the next measure
1387
- // Build pending octave state to return
1407
+ // Emit one visible octave span for a continuing ottava; a repeated same-value command can extend it to the next measure.
1408
+ if (currentOctave && lastNoteId && !currentOctave.emitted) {
1409
+ const endToken = `__OTTAVA_END_${generateId('octaveEnd')}__`;
1410
+ octaves.push({
1411
+ dis: currentOctave.dis,
1412
+ disPlace: currentOctave.disPlace,
1413
+ startId: currentOctave.startId,
1414
+ endId: endToken,
1415
+ });
1416
+ currentOctave.emitted = true;
1417
+ currentOctave.endToken = endToken;
1418
+ currentOctave.endFallbackId = lastNoteId;
1419
+ }
1388
1420
  const pendingOctave: PendingOctave | null = currentOctave
1389
- ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
1421
+ ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
1390
1422
  : null;
1391
1423
 
1392
1424
  xml += `${indent}</layer>\n`;
1393
- return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
1425
+ return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
1394
1426
  };
1395
1427
 
1396
1428
  // Staff result type
@@ -1417,6 +1449,7 @@ interface StaffResult {
1417
1449
  pendingOctaves: OttavaState; // For cross-measure ottava span tracking
1418
1450
  ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
1419
1451
  lastNoteIds: Record<string, string | null>; // For cross-measure ottava span end tracking
1452
+ octaveEndReplacements: Record<string, string>;
1420
1453
  endingClef?: Clef; // For cross-measure clef tracking
1421
1454
  }
1422
1455
 
@@ -1445,6 +1478,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1445
1478
  const pendingOctaves: OttavaState = {};
1446
1479
  const ottavaExplicitlyClosed: Record<string, boolean> = {};
1447
1480
  const lastNoteIds: Record<string, string | null> = {};
1481
+ const octaveEndReplacements: Record<string, string> = {};
1448
1482
  let endingClef: Clef | undefined = initialClef;
1449
1483
 
1450
1484
  if (voices.length === 0) {
@@ -1474,6 +1508,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1474
1508
  allHarmonies.push(...result.harmonies);
1475
1509
  allBarlines.push(...result.barlines);
1476
1510
  allMarkups.push(...result.markups);
1511
+ Object.assign(octaveEndReplacements, result.octaveEndReplacements);
1477
1512
  // Track pending ties for this layer
1478
1513
  if (result.pendingTiePitches.length > 0) {
1479
1514
  pendingTies[tieKey] = result.pendingTiePitches;
@@ -1527,6 +1562,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1527
1562
  pendingOctaves,
1528
1563
  ottavaExplicitlyClosed,
1529
1564
  lastNoteIds,
1565
+ octaveEndReplacements,
1530
1566
  endingClef,
1531
1567
  };
1532
1568
  };
@@ -1614,7 +1650,7 @@ const BARLINE_TO_MEI: Record<string, string> = {
1614
1650
 
1615
1651
  // Encode a measure
1616
1652
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1617
- const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
1653
+ const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}): string => {
1618
1654
  const measureId = generateId("measure");
1619
1655
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1620
1656
  const allHairpins: HairpinSpan[] = [];
@@ -1690,38 +1726,22 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1690
1726
  allHarmonies.push(...result.harmonies);
1691
1727
  allBarlines.push(...result.barlines);
1692
1728
  allMarkups.push(...result.markups);
1729
+ Object.assign(octaveEndReplacements, result.octaveEndReplacements);
1693
1730
  // Update tie state with pending ties from this staff
1694
1731
  Object.assign(tieState, result.pendingTies);
1695
1732
  // Update slur state with pending slurs from this staff
1696
1733
  Object.assign(slurState, result.pendingSlurs);
1697
1734
  // Update hairpin state with pending hairpins from this staff
1698
1735
  Object.assign(hairpinState, result.pendingHairpins);
1699
- // Update ottava state with pending octaves from this staff
1700
- // Also handle closing spans when ottava ends
1736
+ // Update ottava state with pending octaves from this staff.
1737
+ // encodeLayer already emits measure-local octave spans, so keep the next measure's start independent.
1701
1738
  const currentStaffPrefix = `${si}-`;
1702
1739
  for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1703
1740
  if (pending) {
1704
- // Check if this is a continuation or a new span
1705
- const prevPending = ottavaState[key];
1706
- if (prevPending && prevPending.shift === pending.shift) {
1707
- // Same ottava value continues - keep the original startId
1708
- ottavaState[key] = { ...pending, startId: prevPending.startId };
1709
- } else {
1710
- // Different ottava value - close the old span first if exists
1711
- if (prevPending) {
1712
- const lastNoteId = result.lastNoteIds[key];
1713
- if (lastNoteId) {
1714
- allOctaves.push({
1715
- dis: prevPending.dis,
1716
- disPlace: prevPending.disPlace,
1717
- startId: prevPending.startId,
1718
- endId: lastNoteId,
1719
- });
1720
- }
1721
- }
1722
- // Start new span
1723
- ottavaState[key] = pending;
1741
+ if (pending.endToken && pending.endFallbackId && !octaveEndReplacements[pending.endToken]) {
1742
+ octaveEndReplacements[pending.endToken] = pending.endFallbackId;
1724
1743
  }
1744
+ ottavaState[key] = pending;
1725
1745
  }
1726
1746
  }
1727
1747
  // For layers in this staff that had pending octaves but didn't in this measure, close the spans
@@ -2325,6 +2345,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2325
2345
 
2326
2346
  // Track ottava state across measures for cross-measure ottava spans
2327
2347
  const ottavaState: OttavaState = {};
2348
+ const octaveEndReplacements: Record<string, string> = {};
2328
2349
 
2329
2350
  // Initialize clef state from partInfos (convert local staff to global staff)
2330
2351
  const clefState: ClefState = {};
@@ -2384,7 +2405,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2384
2405
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
2385
2406
  }
2386
2407
  }
2387
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
2408
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2388
2409
  });
2389
2410
 
2390
2411
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
@@ -2394,6 +2415,10 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2394
2415
  mei += `${indent}</music>\n`;
2395
2416
  mei += '</mei>\n';
2396
2417
 
2418
+ for (const [token, endId] of Object.entries(octaveEndReplacements)) {
2419
+ mei = mei.replaceAll(token, endId);
2420
+ }
2421
+
2397
2422
  return mei;
2398
2423
  };
2399
2424