@k-l-lambda/lilylet 0.1.63 → 0.1.64
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.
- package/lib/lilylet/meiEncoder.js +58 -40
- package/lib/source/abc/abc.d.ts +102 -0
- package/lib/source/abc/abc.js +25 -0
- package/lib/source/abc/parser.d.ts +3 -0
- package/lib/source/abc/parser.js +6 -0
- package/lib/source/lilylet/abcDecoder.d.ts +25 -0
- package/lib/source/lilylet/abcDecoder.js +1035 -0
- package/lib/source/lilylet/index.d.ts +10 -0
- package/lib/source/lilylet/index.js +10 -0
- package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/source/lilylet/lilypondDecoder.js +1223 -0
- package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/source/lilylet/lilypondEncoder.js +893 -0
- package/lib/source/lilylet/meiEncoder.d.ts +8 -0
- package/lib/source/lilylet/meiEncoder.js +1985 -0
- package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/source/lilylet/musicXmlEncoder.js +701 -0
- package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/source/lilylet/musicXmlTypes.js +7 -0
- package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/source/lilylet/musicXmlUtils.js +469 -0
- package/lib/source/lilylet/parser.d.ts +14 -0
- package/lib/source/lilylet/parser.js +161 -0
- package/lib/source/lilylet/serializer.d.ts +11 -0
- package/lib/source/lilylet/serializer.js +791 -0
- package/lib/source/lilylet/types.d.ts +253 -0
- package/lib/source/lilylet/types.js +100 -0
- package/lib/tests/abc-abcjs-parse.d.ts +8 -0
- package/lib/tests/abc-abcjs-parse.js +90 -0
- package/lib/tests/abc-abcjs-svg.d.ts +1 -0
- package/lib/tests/abc-abcjs-svg.js +143 -0
- package/lib/tests/abc-decoder.d.ts +1 -0
- package/lib/tests/abc-decoder.js +67 -0
- package/lib/tests/abc-mei-compare.d.ts +1 -0
- package/lib/tests/abc-mei-compare.js +525 -0
- package/lib/tests/auto-beam.d.ts +9 -0
- package/lib/tests/auto-beam.js +151 -0
- package/lib/tests/computeMeiHashes.d.ts +1 -0
- package/lib/tests/computeMeiHashes.js +87 -0
- package/lib/tests/encoder-mutation.d.ts +9 -0
- package/lib/tests/encoder-mutation.js +110 -0
- package/lib/tests/gpt-review-issues.d.ts +5 -0
- package/lib/tests/gpt-review-issues.js +255 -0
- package/lib/tests/json-to-lyl.d.ts +1 -0
- package/lib/tests/json-to-lyl.js +18 -0
- package/lib/tests/lilypond-roundtrip.d.ts +7 -0
- package/lib/tests/lilypond-roundtrip.js +558 -0
- package/lib/tests/lilypondDecoder.d.ts +6 -0
- package/lib/tests/lilypondDecoder.js +95 -0
- package/lib/tests/ly-to-lyl.d.ts +1 -0
- package/lib/tests/ly-to-lyl.js +12 -0
- package/lib/tests/mei.d.ts +1 -0
- package/lib/tests/mei.js +278 -0
- package/lib/tests/musicxml-decoder.d.ts +4 -0
- package/lib/tests/musicxml-decoder.js +61 -0
- package/lib/tests/musicxml-detail.d.ts +4 -0
- package/lib/tests/musicxml-detail.js +85 -0
- package/lib/tests/musicxml-fprod.d.ts +9 -0
- package/lib/tests/musicxml-fprod.js +153 -0
- package/lib/tests/musicxml-roundtrip.d.ts +7 -0
- package/lib/tests/musicxml-roundtrip.js +296 -0
- package/lib/tests/musicxml-to-mei.d.ts +6 -0
- package/lib/tests/musicxml-to-mei.js +115 -0
- package/lib/tests/parser.d.ts +1 -0
- package/lib/tests/parser.js +17 -0
- package/lib/tests/render-k283.d.ts +1 -0
- package/lib/tests/render-k283.js +33 -0
- package/lib/tests/render-lyl.d.ts +1 -0
- package/lib/tests/render-lyl.js +35 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
- package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
- package/lib/tests/unit/gptReviewIssues.test.js +240 -0
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
- package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
- package/lib/tests/unit/partialWarning.test.d.ts +4 -0
- package/lib/tests/unit/partialWarning.test.js +65 -0
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
- package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
- package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
- package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
- package/package.json +1 -1
- 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.
|
|
3
|
+
"version": "0.1.64",
|
|
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",
|
|
@@ -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
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
|
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
|
-
//
|
|
1387
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1705
|
-
|
|
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
|
|