@k-l-lambda/lilylet 0.1.74 → 0.1.76

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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Derive a performance-order measure-layout string from a MusicXML part's
3
+ * repeat / volta / navigation markup.
4
+ *
5
+ * Input: the raw per-measure repeat structure collected from `<barline>`
6
+ * (forward/backward repeats, 1st/2nd-ending voltas) and `<direction><sound>`
7
+ * (D.C./D.S./Coda/Fine jumps), in 1-based measure index order (one entry per
8
+ * decoded lilylet measure — no merging of split bars, so indices map 1:1).
9
+ *
10
+ * Output: a lotus-style measure-layout string (e.g. "2*[1..4]",
11
+ * "1, 2*[2..7]{[8,9], 10}, 11..15", "1..19, 3..10, 20, 21") suitable for
12
+ * `metadata.measureLayout`, which the MEI encoder turns into an <expansion> and
13
+ * verovio unfolds for MIDI. See [[lilylet-measure-layout]].
14
+ *
15
+ * Strategy: produce the flat performed-order index sequence by simulating the
16
+ * repeats/jumps (this is always correct, even for D.C.-al-Fine-over-a-volta
17
+ * which the structured DSL can't compactly express), then COMPACT it back into
18
+ * the DSL via the shared serializer when the structure is a clean single
19
+ * repeat/volta/segno-jump. Compaction is best-effort; the flat form is the
20
+ * always-valid fallback.
21
+ */
22
+
23
+ import { parseMeasureLayout, expandMeasureLayout } from "./measureLayout";
24
+ import { getDirectChildren, getAttribute } from "./musicXmlUtils";
25
+
26
+ // One decoded measure's repeat-relevant markup, in source order.
27
+ export interface MeasureRepeatInfo {
28
+ index: number; // 1-based decoded measure index
29
+ repeatStart?: boolean; // <barline location="left"><repeat direction="forward">
30
+ repeatEnd?: boolean; // <barline location="right"><repeat direction="backward">
31
+ repeatTimes?: number; // <repeat times="N"> (on the backward repeat)
32
+ endingStart?: number; // <ending number="N" type="start"> begins here
33
+ endingStop?: number; // <ending number="N" type="stop"|"discontinue"> ends here
34
+ segno?: boolean; // a segno point (sound segno= / <segno> glyph)
35
+ coda?: boolean; // a coda landing point (sound coda= / <coda> glyph)
36
+ tocoda?: boolean; // "to coda" jump origin (sound tocoda=)
37
+ dacapo?: boolean; // da capo jump (sound dacapo="yes")
38
+ dalsegno?: boolean; // dal segno jump (sound dalsegno=)
39
+ fine?: boolean; // Fine stop point (sound fine="yes")
40
+ }
41
+
42
+ // Collapse runs of ≥3 consecutive ascending indices into "a..b"; emit the rest
43
+ // comma-separated. Mirrors lotus seqToCode for a flat index list.
44
+ const flatToCode = (seq: number[]): string => {
45
+ let code = "";
46
+ let inRange = false;
47
+ for (let i = 0; i < seq.length; i++) {
48
+ const middle = seq[i - 1] === seq[i] - 1 && seq[i + 1] === seq[i] + 1;
49
+ if (middle) {
50
+ if (!inRange) { code += ".."; inRange = true; }
51
+ }
52
+ else {
53
+ if (i > 0 && !inRange) code += ", ";
54
+ inRange = false;
55
+ code += seq[i].toString();
56
+ }
57
+ }
58
+ return code;
59
+ };
60
+
61
+
62
+ // ── Performed-order simulator ────────────────────────────────────────────
63
+ // Walks the measures executing repeats and navigation jumps, producing the
64
+ // flat 1-based performed-order index sequence. Always correct; the structured
65
+ // compaction below is only a prettier rendering of clean cases.
66
+ //
67
+ // Model (single-level, matches the corpus): a backward repeat sends play back
68
+ // to the most recent forward repeat (or measure 1). Voltas: on the final pass
69
+ // of a repeated section, skip any ending whose number is below the pass count
70
+ // — i.e. take the Nth ending on the Nth pass. D.C./D.S. jump to the start /
71
+ // segno once, then play to Fine or through To-Coda→Coda.
72
+
73
+ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
74
+ const n = infos.length;
75
+ if (n === 0) return [];
76
+ const byIndex = new Map<number, MeasureRepeatInfo>();
77
+ for (const info of infos) byIndex.set(info.index, info);
78
+
79
+ const segnoIdx = infos.find(i => i.segno)?.index ?? 1;
80
+ const codaIdx = infos.find(i => i.coda)?.index;
81
+
82
+ // Ending (volta) spans, sorted by start index. On pass P of the enclosing
83
+ // repeat, the ending whose number === P is played; others are skipped by
84
+ // jumping to the next ending's start (the last ending is the fallthrough).
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;
91
+
92
+ const order: number[] = [];
93
+ const passCount = new Map<number, number>(); // forward-repeat start index → completed passes
94
+ let repeatStartStack: number[] = [1]; // implicit start at measure 1
95
+ let i = 1;
96
+ let jumpedDaCapo = false;
97
+ let jumpedDalSegno = false;
98
+ let toCodaArmed = false; // a D.S./D.C. pass arms the To-Coda jump
99
+ let guard = 0;
100
+ const GUARD_MAX = n * 8 + 64;
101
+
102
+ while (i >= 1 && i <= n && guard++ < GUARD_MAX) {
103
+ const info = byIndex.get(i);
104
+
105
+ if (info?.repeatStart && !repeatStartStack.includes(i)) repeatStartStack.push(i);
106
+
107
+ // Volta: decide whether to play or skip this ending on the current pass.
108
+ if (info?.endingStart !== undefined) {
109
+ const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
110
+ const currentPass = (passCount.get(start) ?? 0) + 1;
111
+ const isLastEnding = endingSpans[endingSpans.length - 1]?.start === i;
112
+ if (info.endingStart !== currentPass && !isLastEnding) {
113
+ const next = nextEndingStartAfter(i);
114
+ if (next !== undefined) { i = next; continue; }
115
+ // no further ending: fall past all endings
116
+ i = i + 1; continue;
117
+ }
118
+ }
119
+
120
+ order.push(i);
121
+
122
+ // To-Coda jump (only on a post-jump pass — the D.S./D.C. al Coda convention)
123
+ if (info?.tocoda && toCodaArmed && codaIdx !== undefined) { i = codaIdx; continue; }
124
+
125
+ // Fine stop (only after a D.C./D.S. jump has occurred)
126
+ if (info?.fine && (jumpedDaCapo || jumpedDalSegno)) break;
127
+
128
+ // Backward repeat → jump to current repeat-start, counting the pass
129
+ if (info?.repeatEnd) {
130
+ const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
131
+ const times = info.repeatTimes ?? 2;
132
+ const done = (passCount.get(start) ?? 0) + 1;
133
+ passCount.set(start, done);
134
+ if (done < times) { i = start; continue; }
135
+ repeatStartStack = repeatStartStack.filter(s => s !== start);
136
+ i++; continue;
137
+ }
138
+
139
+ // D.S. (dal segno) — jump back to segno once, arm To-Coda
140
+ if (info?.dalsegno && !jumpedDalSegno) { jumpedDalSegno = true; toCodaArmed = true; i = segnoIdx; continue; }
141
+
142
+ // D.C. (da capo) — jump back to the start once, arm To-Coda
143
+ if (info?.dacapo && !jumpedDaCapo) { jumpedDaCapo = true; toCodaArmed = true; i = 1; continue; }
144
+
145
+ i++;
146
+ }
147
+
148
+ return order;
149
+ };
150
+
151
+
152
+ // ── Structured compaction ────────────────────────────────────────────────
153
+ // Recognize the clean single-section shapes and render them compactly. For a
154
+ // plain repeat → "T*[a..b]"; for a repeat with 1st/2nd endings →
155
+ // "pre, T*[body]{[e1…], [e2…]}, post". Anything more tangled (nested repeats,
156
+ // D.C./D.S. over a volta, segno jumps) is left to the flat fallback.
157
+
158
+ const rangeCode = (a: number, b: number): string => a === b ? `${a}` : flatToCode(Array.from({ length: b - a + 1 }, (_, k) => a + k));
159
+
160
+ // Render the repeat/volta structure over measures [lo..hi] (no navigation),
161
+ // or null if it isn't a single clean repeat/volta span. Shared by the plain
162
+ // path and the da-capo ABA wrapper.
163
+ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): string | null => {
164
+ const span = infos.filter(i => i.index >= lo && i.index <= hi);
165
+ const repeatEnds = span.filter(i => i.repeatEnd);
166
+ if (repeatEnds.length > 1) return null; // >1 repeat span → caller falls back to flat
167
+
168
+ if (repeatEnds.length === 0) {
169
+ // no repeat at all in this span: a plain ascending run (only valid if the
170
+ // caller still wants a structured wrapper around it)
171
+ const hasEnding = span.some(i => i.endingStart !== undefined);
172
+ if (hasEnding) return null;
173
+ return rangeCode(lo, hi);
174
+ }
175
+
176
+ const endInfo = repeatEnds[0];
177
+ const startIdx = span.find(i => i.repeatStart)?.index ?? lo;
178
+ const times = endInfo.repeatTimes ?? 2;
179
+ const endingStarts = span.filter(i => i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
180
+
181
+ if (endingStarts.length === 0) {
182
+ // plain repeat: pre, T*[start..end], post
183
+ const parts: string[] = [];
184
+ if (startIdx > lo) parts.push(rangeCode(lo, startIdx - 1));
185
+ parts.push(`${times}*[${rangeCode(startIdx, endInfo.index)}]`);
186
+ if (endInfo.index < hi) parts.push(rangeCode(endInfo.index + 1, hi));
187
+ return parts.join(", ");
188
+ }
189
+
190
+ // volta: body = [startIdx .. firstEndingStart-1]; endings partition the rest
191
+ // up to where the last ending stops; tail follows.
192
+ const firstEnding = endingStarts[0];
193
+ const bodyEnd = firstEnding - 1;
194
+ if (bodyEnd < startIdx) return null;
195
+ const endings: number[][] = [];
196
+ for (let e = 0; e < endingStarts.length; e++) {
197
+ const s = endingStarts[e];
198
+ const nextS = endingStarts[e + 1];
199
+ let stop: number;
200
+ if (nextS !== undefined) stop = nextS - 1;
201
+ else {
202
+ const stopInfo = span.find(info => info.index >= s && info.endingStop !== undefined);
203
+ stop = stopInfo ? stopInfo.index : s;
204
+ }
205
+ endings.push(Array.from({ length: stop - s + 1 }, (_, k) => s + k));
206
+ }
207
+ const lastStop = endings[endings.length - 1][endings[endings.length - 1].length - 1];
208
+
209
+ const parts: string[] = [];
210
+ if (startIdx > lo) parts.push(rangeCode(lo, startIdx - 1));
211
+ const altCode = endings.map(sp => sp.length > 1 ? `[${flatToCode(sp)}]` : `${sp[0]}`).join(", ");
212
+ parts.push(`${times}*[${rangeCode(startIdx, bodyEnd)}]{${altCode}}`);
213
+ if (lastStop < hi) parts.push(rangeCode(lastStop + 1, hi));
214
+ return parts.join(", ");
215
+ };
216
+
217
+ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null => {
218
+ const dacapo = infos.find(i => i.dacapo);
219
+ const dalsegno = infos.some(i => i.dalsegno);
220
+ const tocoda = infos.some(i => i.tocoda);
221
+
222
+ // D.C. (da capo → return to measure 1) over an inner repeat/volta maps cleanly
223
+ // to ABA <main, rest>: the trailing A' re-expansion uses LayoutType.Once (body
224
+ // once + the LAST alternate), which is exactly the "over-volta" convention —
225
+ // on the da-capo pass internal volta repeats are dropped, only the final
226
+ // pass-through plays, stopping at Fine. D.S. (segno ≠ measure 1) and To-Coda
227
+ // truncation can't be expressed this way → flat fallback.
228
+ if (dacapo && !dalsegno && !tocoda) {
229
+ // D.C. over a volta → ABA <main, rest>; Once re-expansion gives the
230
+ // over-volta replay (body + last ending, no inner repeat). Plain D.C. al
231
+ // Fine without a volta can't bound A' at an arbitrary Fine → flat fallback.
232
+ return buildDaCapoABA(infos, dacapo.index);
233
+ }
234
+ if (dalsegno || tocoda || infos.some(i => i.fine || i.segno || i.coda)) return null;
235
+
236
+ // No navigation: a single plain repeat/volta over the whole piece.
237
+ return renderRepeatSpan(infos, 1, total);
238
+ };
239
+
240
+ // Construct the ABA form <main, rest> for a D.C. case. main = the volta/repeat
241
+ // structure; rest = the post-volta tail before the D.C. measure. The Once
242
+ // re-expansion of main supplies the da-capo replay (body + last ending), giving
243
+ // the over-volta semantics. Returns null when the shape doesn't fit (no volta,
244
+ // or no tail to form the ABA "rest") → caller uses the flat fallback.
245
+ const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
246
+ const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
247
+ if (endingStarts.length === 0) return null; // plain D.C. al Fine (no volta) → flat
248
+ // body+endings up to the last ending stop; the rest (tail) runs to dcIdx.
249
+ const repeatEnd = infos.find(i => i.index <= dcIdx && i.repeatEnd);
250
+ const startIdx = infos.find(i => i.index <= dcIdx && i.repeatStart)?.index ?? 1;
251
+ const times = repeatEnd?.repeatTimes ?? 2;
252
+ const bodyEnd = endingStarts[0] - 1;
253
+ if (bodyEnd < startIdx) return null;
254
+
255
+ const endings: number[][] = [];
256
+ for (let e = 0; e < endingStarts.length; e++) {
257
+ const s = endingStarts[e];
258
+ const nextS = endingStarts[e + 1];
259
+ let stop: number;
260
+ if (nextS !== undefined) stop = nextS - 1;
261
+ else {
262
+ const stopInfo = infos.find(info => info.index >= s && info.endingStop !== undefined);
263
+ stop = stopInfo ? stopInfo.index : s;
264
+ }
265
+ endings.push(Array.from({ length: stop - s + 1 }, (_, k) => s + k));
266
+ }
267
+ const lastStop = endings[endings.length - 1][endings[endings.length - 1].length - 1];
268
+ const altCode = endings.map(sp => sp.length > 1 ? `[${flatToCode(sp)}]` : `${sp[0]}`).join(", ");
269
+ const voltaCode = `${times}*[${rangeCode(startIdx, bodyEnd)}]{${altCode}}`;
270
+
271
+ const pre = startIdx > 1 ? rangeCode(1, startIdx - 1) + ", " : "";
272
+ const main = pre ? `[${pre}${voltaCode}]` : voltaCode;
273
+ // rest = the tail from after the last ending to the D.C. measure
274
+ if (lastStop >= dcIdx) return null; // no tail to form the ABA "rest"
275
+ const rest = rangeCode(lastStop + 1, dcIdx);
276
+ return `<${main}, ${rest}>`;
277
+ };
278
+
279
+
280
+ /**
281
+ * Build a measure-layout string from a part's repeat structure, or undefined if
282
+ * there is nothing to unfold (no repeats/voltas/navigation). The result is
283
+ * guaranteed to expand (via measureLayout.ts) to the simulated performed order.
284
+ */
285
+ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: number): string | undefined => {
286
+ const hasAny = infos.some(i =>
287
+ i.repeatStart || i.repeatEnd || i.endingStart !== undefined ||
288
+ i.dacapo || i.dalsegno || i.tocoda || i.fine);
289
+ if (!hasAny) return undefined;
290
+
291
+ const performed = simulate(infos);
292
+ if (performed.length === 0) return undefined;
293
+ // No actual unfolding happened (e.g. a lone start-repeat with no end): skip.
294
+ if (performed.length === totalMeasures && performed.every((v, k) => v === k + 1)) return undefined;
295
+
296
+ const structured = tryStructured(infos, totalMeasures);
297
+ if (structured) {
298
+ // Trust the structured form only if it reproduces the simulated order.
299
+ try {
300
+ const got = expandMeasureLayout(parseMeasureLayout(structured));
301
+ if (JSON.stringify(got) === JSON.stringify(performed)) return structured;
302
+ } catch { /* fall through to flat */ }
303
+ }
304
+ return flatToCode(performed);
305
+ };
306
+
307
+
308
+ // Recognize navigation semantics from a <words> string (case-insensitive).
309
+ // "D.C. al Fine" → dacapo (the Fine stop is marked separately where "Fine"
310
+ // appears); "D.S. al Coda"/"D.S." → dalsegno; "To Coda" → tocoda; bare "Fine"
311
+ // → fine; bare "Coda"/"Segno" → mark the landing point.
312
+ const applyNavText = (info: MeasureRepeatInfo, raw: string): void => {
313
+ const t = raw.trim().toLowerCase().replace(/\s+/g, " ");
314
+ if (!t) return;
315
+ const isDC = /\bd\.?\s*c\.?\b|\bda\s*capo\b/.test(t);
316
+ const isDS = /\bd\.?\s*s\.?\b|\bdal\s*segno\b/.test(t);
317
+ if (isDC) info.dacapo = true;
318
+ if (isDS) info.dalsegno = true;
319
+ if (/\bto\s*coda\b/.test(t)) info.tocoda = true;
320
+ // "al Fine" / standalone "Fine" → this measure (or the jump) ends at Fine.
321
+ // A D.C./D.S. "al Fine" phrase names the STOP target, which is wherever the
322
+ // "Fine" label sits — so only the bare/al-Fine "fine" word sets the stop flag.
323
+ if (/\bfine\b/.test(t) && !isDC && !isDS) info.fine = true;
324
+ // bare "Coda"/"Segno" word (not "to coda", not "al coda") marks the landing
325
+ if (/\bcoda\b/.test(t) && !/\bto\s*coda\b/.test(t) && !/\bal\s*coda\b/.test(t) && !isDS) info.coda = true;
326
+ if (/\bsegno\b/.test(t) && !isDS) info.segno = true;
327
+ };
328
+
329
+ // ── Collector: walk a <part>'s measures → MeasureRepeatInfo[] ─────────────
330
+ // One entry per <measure> in document order (1-based index = decoded lilylet
331
+ // measure index, since we never merge measures). Reads <barline> repeats/endings
332
+ // and <direction><sound> navigation. Repeat markup is part-global and (by
333
+ // convention) identical across parts, so the decoder calls this on the first part.
334
+
335
+ export const collectRepeatInfo = (partEl: Element): MeasureRepeatInfo[] => {
336
+ const infos: MeasureRepeatInfo[] = [];
337
+ const measureEls = getDirectChildren(partEl, "measure");
338
+ for (let m = 0; m < measureEls.length; m++) {
339
+ const measureEl = measureEls[m];
340
+ const info: MeasureRepeatInfo = { index: m + 1 };
341
+
342
+ for (const bl of getDirectChildren(measureEl, "barline")) {
343
+ const repeatEl = bl.getElementsByTagName("repeat")[0];
344
+ if (repeatEl) {
345
+ const dir = getAttribute(repeatEl, "direction");
346
+ if (dir === "forward") info.repeatStart = true;
347
+ else if (dir === "backward") {
348
+ info.repeatEnd = true;
349
+ const times = getAttribute(repeatEl, "times");
350
+ if (times) info.repeatTimes = parseInt(times, 10);
351
+ }
352
+ }
353
+ const endingEl = bl.getElementsByTagName("ending")[0];
354
+ if (endingEl) {
355
+ const type = getAttribute(endingEl, "type");
356
+ const numStr = getAttribute(endingEl, "number") || "1";
357
+ // number can be "1" or "1,2"; take the first integer
358
+ const num = parseInt(numStr, 10) || 1;
359
+ if (type === "start") info.endingStart = num;
360
+ else if (type === "stop" || type === "discontinue") info.endingStop = num;
361
+ }
362
+ }
363
+
364
+ for (const dirEl of getDirectChildren(measureEl, "direction")) {
365
+ const soundEl = dirEl.getElementsByTagName("sound")[0];
366
+ if (soundEl) {
367
+ if (getAttribute(soundEl, "dacapo") === "yes") info.dacapo = true;
368
+ if (getAttribute(soundEl, "fine") === "yes") info.fine = true;
369
+ if (getAttribute(soundEl, "dalsegno")) info.dalsegno = true;
370
+ if (getAttribute(soundEl, "segno")) info.segno = true;
371
+ if (getAttribute(soundEl, "coda")) info.coda = true;
372
+ if (getAttribute(soundEl, "tocoda")) info.tocoda = true;
373
+ }
374
+ // glyph-only segno/coda (no <sound>) still marks the point
375
+ const dtEl = dirEl.getElementsByTagName("direction-type")[0];
376
+ if (dtEl) {
377
+ if (dtEl.getElementsByTagName("segno")[0]) info.segno = true;
378
+ if (dtEl.getElementsByTagName("coda")[0]) info.coda = true;
379
+ }
380
+ // Text-based navigation: many engravers write only the words
381
+ // ("D.C. al Fine", "D.S. al Coda", "Fine", "To Coda") with no <sound>
382
+ // jump attribute. Parse the displayed text to recover the semantics.
383
+ for (const wEl of Array.from(dirEl.getElementsByTagName("words"))) {
384
+ applyNavText(info, wEl.textContent || "");
385
+ }
386
+ }
387
+ // also catch a bare <sound> child of <measure> (outside <direction>)
388
+ for (const soundEl of getDirectChildren(measureEl, "sound")) {
389
+ if (getAttribute(soundEl, "dacapo") === "yes") info.dacapo = true;
390
+ if (getAttribute(soundEl, "fine") === "yes") info.fine = true;
391
+ if (getAttribute(soundEl, "dalsegno")) info.dalsegno = true;
392
+ if (getAttribute(soundEl, "segno")) info.segno = true;
393
+ if (getAttribute(soundEl, "coda")) info.coda = true;
394
+ if (getAttribute(soundEl, "tocoda")) info.tocoda = true;
395
+ }
396
+
397
+ infos.push(info);
398
+ }
399
+ return infos;
400
+ };
401
+
402
+ /**
403
+ * Derive metadata.measureLayout from a <part> element, or undefined if no
404
+ * repeats/voltas/navigation are present. Convenience wrapper combining the
405
+ * collector + builder; never throws (returns undefined on any failure).
406
+ */
407
+ export const measureLayoutFromPart = (partEl: Element): string | undefined => {
408
+ try {
409
+ const infos = collectRepeatInfo(partEl);
410
+ return buildMeasureLayout(infos, infos.length);
411
+ } catch {
412
+ return undefined;
413
+ }
414
+ };
415
+
416
+
417
+