@k-l-lambda/lilylet 0.1.81 → 0.1.83
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/abcDecoder.js +143 -2
- package/lib/lilylet/grammar.jison.js +275 -213
- package/lib/lilylet/highlight.d.ts +1 -1
- package/lib/lilylet/highlight.js +8 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +185 -70
- package/lib/lilylet/meiEncoder.js +50 -8
- package/lib/lilylet/onsets.d.ts +53 -0
- package/lib/lilylet/onsets.js +144 -0
- package/lib/lilylet/serializer.js +36 -0
- package/lib/lilylet/types.d.ts +11 -0
- package/lib/onsets.d.ts +1 -0
- package/lib/onsets.js +1 -0
- package/package.json +7 -1
- package/source/lilylet/abcDecoder.ts +138 -2
- package/source/lilylet/grammar.jison.js +275 -213
- package/source/lilylet/highlight.ts +9 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +67 -5
- package/source/lilylet/measureLayoutFromXml.ts +190 -69
- package/source/lilylet/meiEncoder.ts +62 -9
- package/source/lilylet/onsets.ts +158 -0
- package/source/lilylet/serializer.ts +37 -0
- package/source/lilylet/types.ts +24 -0
|
@@ -18,6 +18,7 @@ export type HighlightScope =
|
|
|
18
18
|
| "hairpin"
|
|
19
19
|
| "header"
|
|
20
20
|
| "keyword"
|
|
21
|
+
| "lyric"
|
|
21
22
|
| "markup"
|
|
22
23
|
| "mode"
|
|
23
24
|
| "navigation"
|
|
@@ -96,6 +97,7 @@ export const HIGHLIGHT_RULES: HighlightRule[] = [
|
|
|
96
97
|
{ re: /(?:\\segno\b)/iy, scope: "navigation" },
|
|
97
98
|
{ re: /(?:\\chords\b)/iy, scope: "keyword" },
|
|
98
99
|
{ re: /(?:\\markup\b)/iy, scope: "markup" },
|
|
100
|
+
{ re: /(?:\\addlyrics\b)/iy, scope: "lyric" },
|
|
99
101
|
{ re: /(?:\\<)/iy, scope: "hairpin" },
|
|
100
102
|
{ re: /(?:\\>)/iy, scope: "hairpin" },
|
|
101
103
|
{ re: /(?:\\!)/iy, scope: "hairpin" },
|
|
@@ -155,6 +157,13 @@ export const HIGHLIGHT_RULES: HighlightRule[] = [
|
|
|
155
157
|
{ re: /(?:=)/iy, scope: "operator" },
|
|
156
158
|
{ re: /(?:[rR])/iy, scope: "rest" },
|
|
157
159
|
{ re: /(?:[sS])/iy, scope: "rest" },
|
|
160
|
+
{ re: /(?:\{)/iy, scope: "brace" },
|
|
161
|
+
{ re: /(?:\})/iy, scope: "brace" },
|
|
162
|
+
{ re: /(?:--)/iy, scope: "lyric" },
|
|
163
|
+
{ re: /(?:__\b)/iy, scope: "lyric" },
|
|
164
|
+
{ re: /(?:_\b)/iy, scope: "lyric" },
|
|
165
|
+
{ re: /(?:"([^\"\\]|\\.)*")/iy, scope: "string" },
|
|
166
|
+
{ re: /(?:[^ \t\r\n{}\"\\]+)/iy, scope: "string" },
|
|
158
167
|
];
|
|
159
168
|
|
|
160
169
|
/**
|
package/source/lilylet/index.ts
CHANGED
|
@@ -34,6 +34,20 @@
|
|
|
34
34
|
};
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
// Build a lyric lane (one \addlyrics block) from raw lyric items. `--` is a
|
|
38
|
+
// connector that hyphenates the preceding syllable (it does not consume a
|
|
39
|
+
// note slot); `_` is a skipped slot; `__` extends the preceding syllable.
|
|
40
|
+
const addlyricsLane = (items) => {
|
|
41
|
+
const syllables = [];
|
|
42
|
+
for (const it of items) {
|
|
43
|
+
if (it.text !== undefined) syllables.push({ text: it.text });
|
|
44
|
+
else if (it.hyphen) { if (syllables.length) syllables[syllables.length - 1].hyphen = true; }
|
|
45
|
+
else if (it.skip) syllables.push({ skip: true });
|
|
46
|
+
else if (it.extend) { if (syllables.length) syllables[syllables.length - 1].extend = true; else syllables.push({ extend: true }); }
|
|
47
|
+
}
|
|
48
|
+
return { syllables };
|
|
49
|
+
};
|
|
50
|
+
|
|
37
51
|
const restEvent = (dur, options = {}) => ({
|
|
38
52
|
type: 'rest',
|
|
39
53
|
duration: dur,
|
|
@@ -51,7 +65,7 @@
|
|
|
51
65
|
mode,
|
|
52
66
|
});
|
|
53
67
|
|
|
54
|
-
const voice = (staff, events) => {
|
|
68
|
+
const voice = (staff, events, lyrics) => {
|
|
55
69
|
// Use the first \staff "N" context event that appears before any musical event
|
|
56
70
|
// (notes/rests/tuplets) as the authoritative voice.staff.
|
|
57
71
|
// Skip pitchReset events (from NEWLINE tokens at voice-line boundaries).
|
|
@@ -67,9 +81,13 @@
|
|
|
67
81
|
}
|
|
68
82
|
break; // first musical/structural event — stop
|
|
69
83
|
}
|
|
84
|
+
// \addlyrics lanes are scoped to THIS voice; verse numbers are 1-based by
|
|
85
|
+
// order of \addlyrics blocks within the voice.
|
|
86
|
+
const lanes = lyrics && lyrics.length ? lyrics.map((lane, i) => ({ verse: i + 1, ...lane })) : undefined;
|
|
70
87
|
return {
|
|
71
88
|
staff: leadingStaff != null ? leadingStaff : 1,
|
|
72
89
|
events,
|
|
90
|
+
lyrics: lanes,
|
|
73
91
|
};
|
|
74
92
|
};
|
|
75
93
|
|
|
@@ -241,6 +259,7 @@
|
|
|
241
259
|
|
|
242
260
|
%option flex unicode case-insensitive
|
|
243
261
|
|
|
262
|
+
%x lyric
|
|
244
263
|
|
|
245
264
|
%%
|
|
246
265
|
|
|
@@ -262,7 +281,7 @@
|
|
|
262
281
|
\[auto\-beam return 'HEADER_AUTOBEAM'
|
|
263
282
|
\] return ']'
|
|
264
283
|
|
|
265
|
-
\"([^\"\\]|\\.)*\"
|
|
284
|
+
\"([^\"\\]|\\.)*\" return 'STRING'
|
|
266
285
|
|
|
267
286
|
"\\clef" return 'CMD_CLEF'
|
|
268
287
|
"\\key" return 'CMD_KEY'
|
|
@@ -292,6 +311,7 @@
|
|
|
292
311
|
"\\segno" return 'CMD_SEGNO'
|
|
293
312
|
"\\chords" return 'CMD_CHORDS'
|
|
294
313
|
"\\markup" return 'CMD_MARKUP'
|
|
314
|
+
"\\addlyrics" { this.pushState('lyric'); return 'CMD_ADDLYRICS'; }
|
|
295
315
|
|
|
296
316
|
"\\<" return 'CMD_CRESC_BEGIN'
|
|
297
317
|
"\\>" return 'CMD_DIM_BEGIN'
|
|
@@ -369,6 +389,17 @@
|
|
|
369
389
|
. {}
|
|
370
390
|
|
|
371
391
|
|
|
392
|
+
<lyric>"{" return '{'
|
|
393
|
+
<lyric>"}" { this.popState(); return '}'; }
|
|
394
|
+
<lyric>[ \t]+ {}
|
|
395
|
+
<lyric>(\r?\n)+ {}
|
|
396
|
+
<lyric>"--" return 'LYRIC_HYPHEN'
|
|
397
|
+
<lyric>"__" return 'LYRIC_EXTENDER'
|
|
398
|
+
<lyric>"_" return 'LYRIC_SKIP'
|
|
399
|
+
<lyric>\"([^\"\\]|\\.)*\" return 'LYRIC_WORD'
|
|
400
|
+
<lyric>[^ \t\r\n{}\"\\]+ return 'LYRIC_WORD'
|
|
401
|
+
|
|
402
|
+
|
|
372
403
|
/lex
|
|
373
404
|
|
|
374
405
|
%start document
|
|
@@ -423,7 +454,7 @@ measures
|
|
|
423
454
|
;
|
|
424
455
|
|
|
425
456
|
measure_content
|
|
426
|
-
: parts
|
|
457
|
+
: parts %{
|
|
427
458
|
// Check \partial declarations: warn if declared duration ≠ actual voice ticks
|
|
428
459
|
let partialDur = null;
|
|
429
460
|
outer: for (const p of $1) {
|
|
@@ -454,6 +485,17 @@ measure_content
|
|
|
454
485
|
%}
|
|
455
486
|
;
|
|
456
487
|
|
|
488
|
+
addlyrics_opt
|
|
489
|
+
: /* empty */ { $$ = undefined; }
|
|
490
|
+
| addlyrics_blocks { $$ = $1; }
|
|
491
|
+
;
|
|
492
|
+
|
|
493
|
+
addlyrics_blocks
|
|
494
|
+
: addlyrics_block { $$ = [$1]; }
|
|
495
|
+
| addlyrics_blocks addlyrics_block { $$ = $1.concat([$2]); }
|
|
496
|
+
| addlyrics_blocks NEWLINE { $$ = $1; }
|
|
497
|
+
;
|
|
498
|
+
|
|
457
499
|
parts
|
|
458
500
|
: part_voices { $$ = [part($1)]; }
|
|
459
501
|
| parts PART_SEP part_start part_voices { $$ = $1.concat([part($4)]); }
|
|
@@ -464,8 +506,12 @@ part_start
|
|
|
464
506
|
;
|
|
465
507
|
|
|
466
508
|
part_voices
|
|
467
|
-
:
|
|
468
|
-
| part_voices VOICE_SEP
|
|
509
|
+
: voice_block { $$ = [voice(currentStaff, $1.events, $1.lyrics)]; }
|
|
510
|
+
| part_voices VOICE_SEP voice_block { $$ = $1.concat([voice(currentStaff, $3.events, $3.lyrics)]); }
|
|
511
|
+
;
|
|
512
|
+
|
|
513
|
+
voice_block
|
|
514
|
+
: voice_events addlyrics_opt { $$ = { events: $1, lyrics: $2 }; }
|
|
469
515
|
;
|
|
470
516
|
|
|
471
517
|
voice_events
|
|
@@ -787,3 +833,19 @@ markup_mark
|
|
|
787
833
|
glissando_mark
|
|
788
834
|
: CMD_GLISSANDO -> glissando()
|
|
789
835
|
;
|
|
836
|
+
|
|
837
|
+
addlyrics_block
|
|
838
|
+
: CMD_ADDLYRICS '{' lyric_sequence '}' -> addlyricsLane($3)
|
|
839
|
+
;
|
|
840
|
+
|
|
841
|
+
lyric_sequence
|
|
842
|
+
: /* empty */ { $$ = []; }
|
|
843
|
+
| lyric_sequence lyric_item { $$ = $1.concat([$2]); }
|
|
844
|
+
;
|
|
845
|
+
|
|
846
|
+
lyric_item
|
|
847
|
+
: LYRIC_WORD -> ({ text: $1.charAt(0) === '"' ? unq($1) : $1 })
|
|
848
|
+
| LYRIC_HYPHEN -> ({ hyphen: true })
|
|
849
|
+
| LYRIC_SKIP -> ({ skip: true })
|
|
850
|
+
| LYRIC_EXTENDER -> ({ extend: true })
|
|
851
|
+
;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* always-valid fallback.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { parseMeasureLayout, expandMeasureLayout } from "./measureLayout";
|
|
23
|
+
import { parseMeasureLayout, expandMeasureLayout, LayoutType } from "./measureLayout";
|
|
24
24
|
import { getDirectChildren, getAttribute } from "./musicXmlUtils";
|
|
25
25
|
|
|
26
26
|
// One decoded measure's repeat-relevant markup, in source order.
|
|
@@ -79,15 +79,9 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
|
|
|
79
79
|
const segnoIdx = infos.find(i => i.segno)?.index ?? 1;
|
|
80
80
|
const codaIdx = infos.find(i => i.coda)?.index;
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
const endingSpans = infos
|
|
86
|
-
.filter(i => i.endingStart !== undefined)
|
|
87
|
-
.map(i => ({ number: i.endingStart!, start: i.index }))
|
|
88
|
-
.sort((a, b) => a.start - b.start);
|
|
89
|
-
const nextEndingStartAfter = (idx: number): number | undefined =>
|
|
90
|
-
endingSpans.find(e => e.start > idx)?.start;
|
|
82
|
+
// Classify repeat/volta sections once so ending-skip decisions are scoped to
|
|
83
|
+
// the current repeat section, not a later independent section's endings.
|
|
84
|
+
const sections = classifyRepeatSections(infos, 1, n);
|
|
91
85
|
|
|
92
86
|
const order: number[] = [];
|
|
93
87
|
const passCount = new Map<number, number>(); // forward-repeat start index → completed passes
|
|
@@ -108,12 +102,14 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
|
|
|
108
102
|
if (info?.endingStart !== undefined) {
|
|
109
103
|
const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
|
|
110
104
|
const currentPass = (passCount.get(start) ?? 0) + 1;
|
|
105
|
+
const section = findSectionForIndex(sections, i);
|
|
106
|
+
const endingSpans = section ? endingSpansForSection(infos, section) : [];
|
|
111
107
|
const isLastEnding = endingSpans[endingSpans.length - 1]?.start === i;
|
|
112
108
|
if (info.endingStart !== currentPass && !isLastEnding) {
|
|
113
|
-
const next =
|
|
109
|
+
const next = endingSpans.find(e => e.start > i)?.start;
|
|
114
110
|
if (next !== undefined) { i = next; continue; }
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
i = section ? section.hi + 1 : i + 1;
|
|
112
|
+
continue;
|
|
117
113
|
}
|
|
118
114
|
}
|
|
119
115
|
|
|
@@ -125,7 +121,10 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
|
|
|
125
121
|
// Fine stop (only after a D.C./D.S. jump has occurred)
|
|
126
122
|
if (info?.fine && (jumpedDaCapo || jumpedDalSegno)) break;
|
|
127
123
|
|
|
128
|
-
// Backward repeat → jump to current repeat-start, counting the pass
|
|
124
|
+
// Backward repeat → jump to current repeat-start, counting the pass.
|
|
125
|
+
// If this same measure ALSO carries a D.C./D.S. (e.g. ABC "!D.C.!:|"), the
|
|
126
|
+
// repeat is resolved FIRST (all passes), then we fall through to the
|
|
127
|
+
// navigation jump below — the da-capo wraps the repeat, not the reverse.
|
|
129
128
|
if (info?.repeatEnd) {
|
|
130
129
|
const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
|
|
131
130
|
const times = info.repeatTimes ?? 2;
|
|
@@ -133,7 +132,10 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
|
|
|
133
132
|
passCount.set(start, done);
|
|
134
133
|
if (done < times) { i = start; continue; }
|
|
135
134
|
repeatStartStack = repeatStartStack.filter(s => s !== start);
|
|
136
|
-
|
|
135
|
+
// repeat exhausted: only advance past it when there is no pending
|
|
136
|
+
// navigation on this measure (otherwise fall through to D.C./D.S.).
|
|
137
|
+
const hasPendingNav = (info.dacapo && !jumpedDaCapo) || (info.dalsegno && !jumpedDalSegno);
|
|
138
|
+
if (!hasPendingNav) { i++; continue; }
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
// D.S. (dal segno) — jump back to segno once, arm To-Coda
|
|
@@ -157,6 +159,119 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
|
|
|
157
159
|
|
|
158
160
|
const rangeCode = (a: number, b: number): string => a === b ? `${a}` : flatToCode(Array.from({ length: b - a + 1 }, (_, k) => a + k));
|
|
159
161
|
|
|
162
|
+
const isAscendingRun = (seq: number[]): boolean => seq.every((v, i) => i === 0 || v === seq[i - 1] + 1);
|
|
163
|
+
|
|
164
|
+
const compactFlatToCode = (seq: number[]): string => {
|
|
165
|
+
const parts: string[] = [];
|
|
166
|
+
let plain: number[] = [];
|
|
167
|
+
const flushPlain = (): void => {
|
|
168
|
+
if (plain.length > 0) {
|
|
169
|
+
parts.push(flatToCode(plain));
|
|
170
|
+
plain = [];
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let i = 0;
|
|
175
|
+
while (i < seq.length) {
|
|
176
|
+
let repeatLen = 0;
|
|
177
|
+
for (let len = Math.floor((seq.length - i) / 2); len >= 1; len--) {
|
|
178
|
+
const chunk = seq.slice(i, i + len);
|
|
179
|
+
if (!isAscendingRun(chunk)) continue;
|
|
180
|
+
const next = seq.slice(i + len, i + 2 * len);
|
|
181
|
+
if (JSON.stringify(chunk) === JSON.stringify(next)) { repeatLen = len; break; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (repeatLen > 0) {
|
|
185
|
+
flushPlain();
|
|
186
|
+
parts.push(`2*[${flatToCode(seq.slice(i, i + repeatLen))}]`);
|
|
187
|
+
i += repeatLen * 2;
|
|
188
|
+
}
|
|
189
|
+
else plain.push(seq[i++]);
|
|
190
|
+
}
|
|
191
|
+
flushPlain();
|
|
192
|
+
return parts.join(", ");
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const layoutExpandsTo = (code: string, seq: number[], type: LayoutType = LayoutType.Full): boolean => {
|
|
196
|
+
try {
|
|
197
|
+
const got = expandMeasureLayout(parseMeasureLayout(code), type);
|
|
198
|
+
return JSON.stringify(got) === JSON.stringify(seq);
|
|
199
|
+
} catch { return false; }
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
interface RepeatSection {
|
|
203
|
+
lo: number;
|
|
204
|
+
hi: number;
|
|
205
|
+
startIdx: number;
|
|
206
|
+
endIdx?: number;
|
|
207
|
+
times: number;
|
|
208
|
+
endingStarts: number[];
|
|
209
|
+
spurious: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const classifyRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): RepeatSection[] => {
|
|
213
|
+
const inSpan = (idx: number): boolean => idx >= lo && idx <= hi;
|
|
214
|
+
const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
215
|
+
const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
216
|
+
const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
217
|
+
|
|
218
|
+
if (repeatEnds.length === 0) {
|
|
219
|
+
return [{ lo, hi, startIdx: lo, times: 1, endingStarts: [], spurious: false }];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sections: RepeatSection[] = [];
|
|
223
|
+
let prevHi = lo - 1;
|
|
224
|
+
for (const e of repeatEnds) {
|
|
225
|
+
if (e <= prevHi) continue;
|
|
226
|
+
const sectionLo = prevHi + 1;
|
|
227
|
+
const nextStart = repeatStarts.find(s => s > e);
|
|
228
|
+
const sectionEndingStarts = infos
|
|
229
|
+
.filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
|
|
230
|
+
.map(i => i.index)
|
|
231
|
+
.sort((a, b) => a - b);
|
|
232
|
+
|
|
233
|
+
let sectionHi: number;
|
|
234
|
+
if (repeatEnds.length === 1) sectionHi = hi;
|
|
235
|
+
else if (sectionEndingStarts.length) {
|
|
236
|
+
const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
|
|
237
|
+
const stop = endingStops.find(s => s >= lastStart);
|
|
238
|
+
sectionHi = stop ?? lastStart;
|
|
239
|
+
}
|
|
240
|
+
else sectionHi = e;
|
|
241
|
+
|
|
242
|
+
const explicitStartInSection = repeatStarts.some(s => s >= sectionLo && s <= e);
|
|
243
|
+
const implicitFromStart = sectionLo === lo && lo === 1;
|
|
244
|
+
const spurious = sectionEndingStarts.length === 0 && !explicitStartInSection && !implicitFromStart;
|
|
245
|
+
const startIdx = repeatStarts.filter(s => s >= sectionLo && s <= e).sort((a, b) => b - a)[0] ?? sectionLo;
|
|
246
|
+
const endInfo = infos.find(i => i.index === e);
|
|
247
|
+
sections.push({ lo: sectionLo, hi: sectionHi, startIdx, endIdx: e, times: endInfo?.repeatTimes ?? 2, endingStarts: sectionEndingStarts, spurious });
|
|
248
|
+
prevHi = sectionHi;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (prevHi < hi) sections.push({ lo: prevHi + 1, hi, startIdx: prevHi + 1, times: 1, endingStarts: [], spurious: false });
|
|
252
|
+
return sections;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const findSectionForIndex = (sections: RepeatSection[], idx: number): RepeatSection | undefined =>
|
|
256
|
+
sections.find(s => idx >= s.lo && idx <= s.hi);
|
|
257
|
+
|
|
258
|
+
const endingSpansForSection = (infos: MeasureRepeatInfo[], section: RepeatSection): Array<{ number: number; start: number; stop: number }> => {
|
|
259
|
+
const spans: Array<{ number: number; start: number; stop: number }> = [];
|
|
260
|
+
for (let e = 0; e < section.endingStarts.length; e++) {
|
|
261
|
+
const start = section.endingStarts[e];
|
|
262
|
+
const nextStart = section.endingStarts[e + 1];
|
|
263
|
+
let stop: number;
|
|
264
|
+
if (nextStart !== undefined) stop = nextStart - 1;
|
|
265
|
+
else {
|
|
266
|
+
const stopInfo = infos.find(info => info.index >= start && info.index <= section.hi && info.endingStop !== undefined);
|
|
267
|
+
stop = stopInfo ? stopInfo.index : start;
|
|
268
|
+
}
|
|
269
|
+
const number = infos.find(info => info.index === start)?.endingStart ?? e + 1;
|
|
270
|
+
spans.push({ number, start, stop });
|
|
271
|
+
}
|
|
272
|
+
return spans;
|
|
273
|
+
};
|
|
274
|
+
|
|
160
275
|
// Render the repeat/volta structure over measures [lo..hi] (no navigation),
|
|
161
276
|
// or null if it isn't a single clean repeat/volta span. Shared by the plain
|
|
162
277
|
// path and the da-capo ABA wrapper.
|
|
@@ -229,64 +344,38 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
|
|
|
229
344
|
// Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
|
|
230
345
|
// halves of a da-capo ABA.
|
|
231
346
|
const renderRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): string | null => {
|
|
232
|
-
const
|
|
233
|
-
const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
234
|
-
const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
235
|
-
if (repeatEnds.length <= 1) return renderRepeatSpan(infos, lo, hi);
|
|
236
|
-
|
|
237
|
-
const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
|
|
238
|
-
|
|
347
|
+
const sections = classifyRepeatSections(infos, lo, hi);
|
|
239
348
|
const parts: string[] = [];
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// endings sit AFTER the repeat-end but BEFORE the next section starts).
|
|
245
|
-
const nextStart = repeatStarts.find(s => s > e);
|
|
246
|
-
const sectionEndingStarts = infos
|
|
247
|
-
.filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
|
|
248
|
-
.map(i => i.index)
|
|
249
|
-
.sort((a, b) => a - b);
|
|
250
|
-
|
|
251
|
-
let sectionHi: number;
|
|
252
|
-
if (sectionEndingStarts.length) {
|
|
253
|
-
const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
|
|
254
|
-
// the last ending's stop closes the section; else the ending start itself
|
|
255
|
-
const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
|
|
256
|
-
sectionHi = stop ?? lastStart;
|
|
349
|
+
for (const section of sections) {
|
|
350
|
+
if (section.spurious) {
|
|
351
|
+
parts.push(rangeCode(section.lo, section.hi));
|
|
352
|
+
continue;
|
|
257
353
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const code = renderRepeatSpan(infos, sectionLo, sectionHi);
|
|
354
|
+
const code = renderRepeatSpan(infos, section.lo, section.hi);
|
|
261
355
|
if (code === null) return null;
|
|
262
356
|
parts.push(code);
|
|
263
|
-
prevHi = sectionHi;
|
|
264
357
|
}
|
|
265
|
-
// trailing non-repeated tail
|
|
266
|
-
if (prevHi < hi) parts.push(rangeCode(prevHi + 1, hi));
|
|
267
358
|
return parts.join(", ");
|
|
268
359
|
};
|
|
269
360
|
|
|
270
|
-
const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null => {
|
|
361
|
+
const tryStructured = (infos: MeasureRepeatInfo[], total: number, performed: number[]): string | null => {
|
|
271
362
|
const dacapo = infos.find(i => i.dacapo);
|
|
272
363
|
const dalsegno = infos.some(i => i.dalsegno);
|
|
273
364
|
const tocoda = infos.some(i => i.tocoda);
|
|
274
365
|
|
|
275
|
-
// D.C.
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// Fine without a volta can't bound A' at an arbitrary Fine → flat fallback.
|
|
285
|
-
return buildDaCapoABA(infos, dacapo.index);
|
|
366
|
+
// D.C. / D.S. jumps that replay from the start can use the ABA form. A real
|
|
367
|
+
// Segno (not measure 1) and To-Coda still fall back to flat because the DSL has
|
|
368
|
+
// no "replay from arbitrary middle marker" construct. Bare Fine/Segno/Coda marks
|
|
369
|
+
// without a jump are visual landmarks only; they must not block repeat structuring.
|
|
370
|
+
const hasExplicitSegno = infos.some(i => i.segno);
|
|
371
|
+
if (!tocoda && dacapo) return buildDaCapoABA(infos, dacapo.index, performed);
|
|
372
|
+
if (!tocoda && dalsegno && !hasExplicitSegno) {
|
|
373
|
+
const ds = infos.find(i => i.dalsegno);
|
|
374
|
+
if (ds) return buildDaCapoABA(infos, ds.index, performed);
|
|
286
375
|
}
|
|
287
|
-
if (dalsegno || tocoda
|
|
376
|
+
if (dalsegno || tocoda) return null;
|
|
288
377
|
|
|
289
|
-
// No navigation: one or more plain repeat/volta sections in sequence. Most
|
|
378
|
+
// No playback-changing navigation: one or more plain repeat/volta sections in sequence. Most
|
|
290
379
|
// real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
|
|
291
380
|
// render each independently and join with commas.
|
|
292
381
|
return renderRepeatSections(infos, 1, total);
|
|
@@ -301,20 +390,19 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
|
|
|
301
390
|
// via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
|
|
302
391
|
// bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
|
|
303
392
|
// re-expand check still validates the result against the simulated order.
|
|
304
|
-
const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
|
|
393
|
+
const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number, performed: number[]): string | null => {
|
|
305
394
|
const fineIdx = infos.find(i => i.fine)?.index;
|
|
306
395
|
if (fineIdx === undefined) {
|
|
307
396
|
// No explicit Fine: fall back to the legacy single-volta ABA shape, where
|
|
308
397
|
// A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
|
|
309
398
|
return buildDaCapoABALegacy(infos, dcIdx);
|
|
310
399
|
}
|
|
311
|
-
if (fineIdx >= dcIdx) return null; // Fine must precede the D.C. tail
|
|
400
|
+
if (fineIdx >= dcIdx) return null; // Fine must precede the D.C./D.S. tail
|
|
312
401
|
const restLo = fineIdx + 1;
|
|
313
|
-
if (restLo > dcIdx) return null; // no B section
|
|
314
402
|
|
|
315
403
|
const mainCode = renderRepeatSections(infos, 1, fineIdx);
|
|
316
|
-
const restCode = renderRepeatSections(infos, restLo, dcIdx);
|
|
317
|
-
if (mainCode === null || restCode === null) return
|
|
404
|
+
const restCode = restLo > dcIdx ? "" : renderRepeatSections(infos, restLo, dcIdx);
|
|
405
|
+
if (mainCode === null || restCode === null) return buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
|
|
318
406
|
|
|
319
407
|
// `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
|
|
320
408
|
// in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
|
|
@@ -325,7 +413,38 @@ const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | nul
|
|
|
325
413
|
const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
|
|
326
414
|
const main = isSingleToken ? mainCode : `[${mainCode}]`;
|
|
327
415
|
const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
|
|
328
|
-
|
|
416
|
+
const candidate = `<${main}, ${rest}>`;
|
|
417
|
+
return layoutExpandsTo(candidate, performed) ? candidate : buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const wrapAbaMain = (code: string): string => /^\d+$/.test(code) || /^\d+\*\[/.test(code) || /^\[/.test(code) ? code : `[${code}]`;
|
|
421
|
+
const wrapAbaRest = (code: string): string => code === "" ? "" : (/,/.test(code) ? `[${code}]` : code);
|
|
422
|
+
|
|
423
|
+
const buildDaCapoABAFromReplay = (infos: MeasureRepeatInfo[], dcIdx: number, fineIdx: number, performed: number[]): string | null => {
|
|
424
|
+
// Some ABC/MusicXML D.C. exports place repeat-end/start boundaries so the
|
|
425
|
+
// notated pre-Fine span (1..Fine) is not enough to render main via source flags,
|
|
426
|
+
// even though the simulated order is a clean ABA: A(full) B A(once). Detect that
|
|
427
|
+
// shape from the final da-capo replay, then reuse the section renderer for the
|
|
428
|
+
// longer A candidate and derive B from the performed middle slice.
|
|
429
|
+
for (let k = Math.min(fineIdx, performed.length - 1); k >= 1; k--) {
|
|
430
|
+
const replay = Array.from({ length: k }, (_, i) => i + 1);
|
|
431
|
+
if (JSON.stringify(performed.slice(-k)) !== JSON.stringify(replay)) continue;
|
|
432
|
+
|
|
433
|
+
for (let mainHi = k; mainHi < dcIdx; mainHi++) {
|
|
434
|
+
const mainCode = renderRepeatSections(infos, 1, mainHi);
|
|
435
|
+
if (mainCode === null) continue;
|
|
436
|
+
const mainFull = expandMeasureLayout(parseMeasureLayout(mainCode));
|
|
437
|
+
const mainOnce = expandMeasureLayout(parseMeasureLayout(mainCode), LayoutType.Once);
|
|
438
|
+
if (JSON.stringify(mainOnce) !== JSON.stringify(replay)) continue;
|
|
439
|
+
if (JSON.stringify(performed.slice(0, mainFull.length)) !== JSON.stringify(mainFull)) continue;
|
|
440
|
+
|
|
441
|
+
const restSeq = performed.slice(mainFull.length, performed.length - mainOnce.length);
|
|
442
|
+
const restCode = compactFlatToCode(restSeq);
|
|
443
|
+
const candidate = `<${wrapAbaMain(mainCode)}, ${wrapAbaRest(restCode)}>`;
|
|
444
|
+
if (layoutExpandsTo(candidate, performed)) return candidate;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
329
448
|
};
|
|
330
449
|
|
|
331
450
|
// Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
|
|
@@ -359,9 +478,9 @@ const buildDaCapoABALegacy = (infos: MeasureRepeatInfo[], dcIdx: number): string
|
|
|
359
478
|
|
|
360
479
|
const pre = startIdx > 1 ? rangeCode(1, startIdx - 1) + ", " : "";
|
|
361
480
|
const main = pre ? `[${pre}${voltaCode}]` : voltaCode;
|
|
362
|
-
// rest = the tail from after the last ending to the D.C. measure
|
|
363
|
-
|
|
364
|
-
const rest = rangeCode(lastStop + 1, dcIdx);
|
|
481
|
+
// rest = the tail from after the last ending to the D.C. measure. It may be
|
|
482
|
+
// empty when the navigation jump follows immediately after the main repeat.
|
|
483
|
+
const rest = lastStop >= dcIdx ? "" : rangeCode(lastStop + 1, dcIdx);
|
|
365
484
|
return `<${main}, ${rest}>`;
|
|
366
485
|
};
|
|
367
486
|
|
|
@@ -382,7 +501,7 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
|
|
|
382
501
|
// No actual unfolding happened (e.g. a lone start-repeat with no end): skip.
|
|
383
502
|
if (performed.length === totalMeasures && performed.every((v, k) => v === k + 1)) return undefined;
|
|
384
503
|
|
|
385
|
-
const structured = tryStructured(infos, totalMeasures);
|
|
504
|
+
const structured = tryStructured(infos, totalMeasures, performed);
|
|
386
505
|
if (structured) {
|
|
387
506
|
// Trust the structured form only if it reproduces the simulated order.
|
|
388
507
|
try {
|
|
@@ -390,6 +509,8 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
|
|
|
390
509
|
if (JSON.stringify(got) === JSON.stringify(performed)) return structured;
|
|
391
510
|
} catch { /* fall through to flat */ }
|
|
392
511
|
}
|
|
512
|
+
const compacted = compactFlatToCode(performed);
|
|
513
|
+
if (compacted !== flatToCode(performed) && layoutExpandsTo(compacted, performed)) return compacted;
|
|
393
514
|
return flatToCode(performed);
|
|
394
515
|
};
|
|
395
516
|
|