@k-l-lambda/lilylet 0.1.74 → 0.1.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/lilylet/grammar.jison.js +180 -175
- package/lib/lilylet/highlight.js +1 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayout.d.ts +68 -0
- package/lib/lilylet/measureLayout.js +412 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +160 -40
- package/lib/lilylet/musicXmlDecoder.js +172 -6
- package/lib/lilylet/musicXmlTypes.d.ts +9 -0
- package/lib/lilylet/serializer.js +124 -10
- package/lib/lilylet/types.d.ts +1 -0
- package/lib/measureLayout.d.ts +1 -0
- package/lib/measureLayout.js +1 -0
- package/lib/measureLayoutFromXml.d.ts +1 -0
- package/lib/measureLayoutFromXml.js +1 -0
- package/package.json +5 -2
- package/source/lilylet/grammar.jison.js +180 -175
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/measureLayout.ts +460 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +149 -39
- package/source/lilylet/musicXmlDecoder.ts +169 -6
- package/source/lilylet/musicXmlTypes.ts +12 -0
- package/source/lilylet/serializer.ts +118 -10
- package/source/lilylet/types.ts +4 -0
|
@@ -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
|
+
|