@k-l-lambda/lilylet 0.1.80 → 0.1.82
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/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +198 -74
- package/lib/lilylet/onsets.d.ts +53 -0
- package/lib/lilylet/onsets.js +144 -0
- package/lib/onsets.d.ts +1 -0
- package/lib/onsets.js +1 -0
- package/package.json +6 -1
- package/source/lilylet/abcDecoder.ts +138 -2
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/measureLayoutFromXml.ts +204 -74
- package/source/lilylet/onsets.ts +158 -0
|
@@ -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.
|
|
@@ -174,7 +289,11 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
|
|
|
174
289
|
}
|
|
175
290
|
|
|
176
291
|
const endInfo = repeatEnds[0];
|
|
177
|
-
|
|
292
|
+
// The repeat-start for THIS end is the latest repeat-start at or before it; a
|
|
293
|
+
// repeat-start sitting AFTER the end (e.g. the second half of a ":|:"/"::" that
|
|
294
|
+
// opens the next section) belongs to a following span, not this one. Falling
|
|
295
|
+
// back to `lo` models the implicit "repeat from the start of the span".
|
|
296
|
+
const startIdx = span.filter(i => i.repeatStart && i.index <= endInfo.index).map(i => i.index).sort((a, b) => b - a)[0] ?? lo;
|
|
178
297
|
const times = endInfo.repeatTimes ?? 2;
|
|
179
298
|
const endingStarts = span.filter(i => i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
|
|
180
299
|
|
|
@@ -225,64 +344,38 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
|
|
|
225
344
|
// Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
|
|
226
345
|
// halves of a da-capo ABA.
|
|
227
346
|
const renderRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): string | null => {
|
|
228
|
-
const
|
|
229
|
-
const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
230
|
-
const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
231
|
-
if (repeatEnds.length <= 1) return renderRepeatSpan(infos, lo, hi);
|
|
232
|
-
|
|
233
|
-
const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
|
|
234
|
-
|
|
347
|
+
const sections = classifyRepeatSections(infos, lo, hi);
|
|
235
348
|
const parts: string[] = [];
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// endings sit AFTER the repeat-end but BEFORE the next section starts).
|
|
241
|
-
const nextStart = repeatStarts.find(s => s > e);
|
|
242
|
-
const sectionEndingStarts = infos
|
|
243
|
-
.filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
|
|
244
|
-
.map(i => i.index)
|
|
245
|
-
.sort((a, b) => a - b);
|
|
246
|
-
|
|
247
|
-
let sectionHi: number;
|
|
248
|
-
if (sectionEndingStarts.length) {
|
|
249
|
-
const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
|
|
250
|
-
// the last ending's stop closes the section; else the ending start itself
|
|
251
|
-
const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
|
|
252
|
-
sectionHi = stop ?? lastStart;
|
|
349
|
+
for (const section of sections) {
|
|
350
|
+
if (section.spurious) {
|
|
351
|
+
parts.push(rangeCode(section.lo, section.hi));
|
|
352
|
+
continue;
|
|
253
353
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const code = renderRepeatSpan(infos, sectionLo, sectionHi);
|
|
354
|
+
const code = renderRepeatSpan(infos, section.lo, section.hi);
|
|
257
355
|
if (code === null) return null;
|
|
258
356
|
parts.push(code);
|
|
259
|
-
prevHi = sectionHi;
|
|
260
357
|
}
|
|
261
|
-
// trailing non-repeated tail
|
|
262
|
-
if (prevHi < hi) parts.push(rangeCode(prevHi + 1, hi));
|
|
263
358
|
return parts.join(", ");
|
|
264
359
|
};
|
|
265
360
|
|
|
266
|
-
const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null => {
|
|
361
|
+
const tryStructured = (infos: MeasureRepeatInfo[], total: number, performed: number[]): string | null => {
|
|
267
362
|
const dacapo = infos.find(i => i.dacapo);
|
|
268
363
|
const dalsegno = infos.some(i => i.dalsegno);
|
|
269
364
|
const tocoda = infos.some(i => i.tocoda);
|
|
270
365
|
|
|
271
|
-
// D.C.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// Fine without a volta can't bound A' at an arbitrary Fine → flat fallback.
|
|
281
|
-
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);
|
|
282
375
|
}
|
|
283
|
-
if (dalsegno || tocoda
|
|
376
|
+
if (dalsegno || tocoda) return null;
|
|
284
377
|
|
|
285
|
-
// 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
|
|
286
379
|
// real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
|
|
287
380
|
// render each independently and join with commas.
|
|
288
381
|
return renderRepeatSections(infos, 1, total);
|
|
@@ -297,26 +390,61 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
|
|
|
297
390
|
// via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
|
|
298
391
|
// bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
|
|
299
392
|
// re-expand check still validates the result against the simulated order.
|
|
300
|
-
const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
|
|
393
|
+
const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number, performed: number[]): string | null => {
|
|
301
394
|
const fineIdx = infos.find(i => i.fine)?.index;
|
|
302
395
|
if (fineIdx === undefined) {
|
|
303
396
|
// No explicit Fine: fall back to the legacy single-volta ABA shape, where
|
|
304
397
|
// A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
|
|
305
398
|
return buildDaCapoABALegacy(infos, dcIdx);
|
|
306
399
|
}
|
|
307
|
-
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
|
|
308
401
|
const restLo = fineIdx + 1;
|
|
309
|
-
if (restLo > dcIdx) return null; // no B section
|
|
310
402
|
|
|
311
403
|
const mainCode = renderRepeatSections(infos, 1, fineIdx);
|
|
312
|
-
const restCode = renderRepeatSections(infos, restLo, dcIdx);
|
|
313
|
-
if (mainCode === null || restCode === null) return
|
|
314
|
-
|
|
315
|
-
// main
|
|
316
|
-
//
|
|
317
|
-
|
|
404
|
+
const restCode = restLo > dcIdx ? "" : renderRepeatSections(infos, restLo, dcIdx);
|
|
405
|
+
if (mainCode === null || restCode === null) return buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
|
|
406
|
+
|
|
407
|
+
// `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
|
|
408
|
+
// in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
|
|
409
|
+
// which parseItem stops at the first number). A lone number, or a single
|
|
410
|
+
// already-structured token ("2*[..]" / "2*[..]{..}" / "[..]"), parses as one item
|
|
411
|
+
// and needs no extra bracket. `rest` is parsed as a sequence, so it only needs
|
|
412
|
+
// bracketing to group a comma sequence — a range there is fine.
|
|
413
|
+
const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
|
|
414
|
+
const main = isSingleToken ? mainCode : `[${mainCode}]`;
|
|
318
415
|
const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
|
|
319
|
-
|
|
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;
|
|
320
448
|
};
|
|
321
449
|
|
|
322
450
|
// Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
|
|
@@ -350,9 +478,9 @@ const buildDaCapoABALegacy = (infos: MeasureRepeatInfo[], dcIdx: number): string
|
|
|
350
478
|
|
|
351
479
|
const pre = startIdx > 1 ? rangeCode(1, startIdx - 1) + ", " : "";
|
|
352
480
|
const main = pre ? `[${pre}${voltaCode}]` : voltaCode;
|
|
353
|
-
// rest = the tail from after the last ending to the D.C. measure
|
|
354
|
-
|
|
355
|
-
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);
|
|
356
484
|
return `<${main}, ${rest}>`;
|
|
357
485
|
};
|
|
358
486
|
|
|
@@ -373,7 +501,7 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
|
|
|
373
501
|
// No actual unfolding happened (e.g. a lone start-repeat with no end): skip.
|
|
374
502
|
if (performed.length === totalMeasures && performed.every((v, k) => v === k + 1)) return undefined;
|
|
375
503
|
|
|
376
|
-
const structured = tryStructured(infos, totalMeasures);
|
|
504
|
+
const structured = tryStructured(infos, totalMeasures, performed);
|
|
377
505
|
if (structured) {
|
|
378
506
|
// Trust the structured form only if it reproduces the simulated order.
|
|
379
507
|
try {
|
|
@@ -381,6 +509,8 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
|
|
|
381
509
|
if (JSON.stringify(got) === JSON.stringify(performed)) return structured;
|
|
382
510
|
} catch { /* fall through to flat */ }
|
|
383
511
|
}
|
|
512
|
+
const compacted = compactFlatToCode(performed);
|
|
513
|
+
if (compacted !== flatToCode(performed) && layoutExpandsTo(compacted, performed)) return compacted;
|
|
384
514
|
return flatToCode(performed);
|
|
385
515
|
};
|
|
386
516
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note-onset extraction from a parsed Lilylet document.
|
|
3
|
+
*
|
|
4
|
+
* Walks measures -> parts -> voices -> events, accumulating each note's ONSET (position
|
|
5
|
+
* within its measure, in duration units of RES=480 per quarter) and its SOUNDING
|
|
6
|
+
* MIDI pitch. Sounding pitch honors octave-/interval-transposing clefs ("treble_8" sounds
|
|
7
|
+
* an octave lower than written, etc.), tracked per voice and PERSISTED across measures.
|
|
8
|
+
*
|
|
9
|
+
* Shared by tools/astServer.ts (the HTTP onset API) and the clef-onset unit test.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { calculateDuration } from "./musicXmlUtils";
|
|
13
|
+
import { Accidental } from "./types";
|
|
14
|
+
|
|
15
|
+
// Onset resolution in ticks per quarter note. calculateDuration() rounds its result to an
|
|
16
|
+
// integer, so it must run at a resolution fine enough to represent every note value exactly:
|
|
17
|
+
// the shared DIVISIONS=4 (MusicXML export) rounds a 32nd (0.5) and a dotted 16th (1.5) to
|
|
18
|
+
// integers, drifting the onset cursor. 480 is divisible by all common note/dot/tuplet values,
|
|
19
|
+
// so durations stay exact. Onsets are reported in these units and also normalized per measure.
|
|
20
|
+
const RES = 480;
|
|
21
|
+
|
|
22
|
+
const PHONET_SEMITONE: Record<string, number> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|
23
|
+
const ACCIDENTAL_SEMITONE: Record<string, number> = {
|
|
24
|
+
[Accidental.natural]: 0, [Accidental.sharp]: 1, [Accidental.flat]: -1,
|
|
25
|
+
[Accidental.doubleSharp]: 2, [Accidental.doubleFlat]: -2,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Diatonic-step -> semitones within an octave (unison..seventh), for "_N"/"^N" clef suffixes.
|
|
29
|
+
const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
|
|
33
|
+
* "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
|
|
34
|
+
* (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
|
|
35
|
+
* A plain clef (no suffix) declares 0.
|
|
36
|
+
*/
|
|
37
|
+
export const clefShift = (clefStr: string): number => {
|
|
38
|
+
const m = /^.*?([_^])(\d+)$/.exec(clefStr || "");
|
|
39
|
+
if (!m) return 0;
|
|
40
|
+
const k = parseInt(m[2], 10) - 1; // diatonic steps above unison
|
|
41
|
+
const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
|
|
42
|
+
return (m[1] === "^" ? 1 : -1) * semis;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C
|
|
47
|
+
* octave = MIDI 60 (see parser.resolveRelativePitch). `shift` is the active clef's
|
|
48
|
+
* written->sounding semitone transposition.
|
|
49
|
+
*/
|
|
50
|
+
export const pitchToMidi = (pitch: any, shift = 0): number => {
|
|
51
|
+
const semi = PHONET_SEMITONE[pitch.phonet] ?? 0;
|
|
52
|
+
const acc = pitch.accidental ? (ACCIDENTAL_SEMITONE[pitch.accidental] ?? 0) : 0;
|
|
53
|
+
return 60 + (pitch.octave || 0) * 12 + semi + acc + shift;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface NoteOnset {
|
|
57
|
+
onset: number; onsetNorm: number; durationDiv: number;
|
|
58
|
+
midi: number[]; staff: number; voice: number; grace: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MeasureOnsets {
|
|
62
|
+
index: number;
|
|
63
|
+
timeSig: { numerator: number; denominator: number } | null;
|
|
64
|
+
measureDivisions: number;
|
|
65
|
+
notes: NoteOnset[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Walk a voice's events, accumulating onset in duration units. Tuplet/times scale their inner
|
|
69
|
+
// durations by ratio (num/den). Grace notes take no time. `clefRef.shift` is the active clef's
|
|
70
|
+
// written->sounding shift, updated by \clef contextChanges (persisted by the caller across
|
|
71
|
+
// measures). Returns the total consumed duration (the voice's played length).
|
|
72
|
+
const walkVoice = (events: any[], staff: number, voice: number,
|
|
73
|
+
scale: number, startCursor: number, clefRef: { shift: number }, out: NoteOnset[]): number => {
|
|
74
|
+
let cursor = startCursor;
|
|
75
|
+
for (const ev of events) {
|
|
76
|
+
if (ev.type === "context" && typeof ev.clef === "string") {
|
|
77
|
+
clefRef.shift = clefShift(ev.clef);
|
|
78
|
+
}
|
|
79
|
+
else if (ev.type === "note") {
|
|
80
|
+
const dur = calculateDuration(ev.duration, RES) * scale;
|
|
81
|
+
const midi = (ev.pitches || []).map((p: any) => pitchToMidi(p, clefRef.shift));
|
|
82
|
+
if (ev.grace) {
|
|
83
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: 0, midi, staff: ev.staff ?? staff, voice, grace: true });
|
|
84
|
+
continue; // grace steals no measure time
|
|
85
|
+
}
|
|
86
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: dur, midi, staff: ev.staff ?? staff, voice, grace: false });
|
|
87
|
+
cursor += dur;
|
|
88
|
+
}
|
|
89
|
+
else if (ev.type === "rest") {
|
|
90
|
+
cursor += calculateDuration(ev.duration, RES) * scale;
|
|
91
|
+
}
|
|
92
|
+
else if (ev.type === "tuplet" || ev.type === "times") {
|
|
93
|
+
const r = ev.ratio || { numerator: 1, denominator: 1 };
|
|
94
|
+
cursor = walkVoice(ev.events || [], staff, voice, scale * (r.numerator / r.denominator), cursor, clefRef, out);
|
|
95
|
+
}
|
|
96
|
+
else if (ev.type === "tremolo") {
|
|
97
|
+
const each = (RES * 4 / ev.division) * scale;
|
|
98
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
|
|
99
|
+
midi: (ev.pitchA || []).map((p: any) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
|
|
100
|
+
cursor += each;
|
|
101
|
+
out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
|
|
102
|
+
midi: (ev.pitchB || []).map((p: any) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
|
|
103
|
+
cursor += each;
|
|
104
|
+
}
|
|
105
|
+
// barline / markup / dynamic / harmony / pitchReset: no time
|
|
106
|
+
}
|
|
107
|
+
return cursor;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-measure note onsets for a parsed LilyletDoc. The active clef transposition is tracked
|
|
112
|
+
* per (positional) voice index and PERSISTS across measures — a \clef stays in force until the
|
|
113
|
+
* next one, so a "treble_8" set in measure 1 still lowers measure 2 by an octave.
|
|
114
|
+
*
|
|
115
|
+
* Measure length (`measureDivisions`, used to normalize onsets to [0,1)) comes from the time
|
|
116
|
+
* signature for a full bar, but an INCOMPLETE bar carrying a `\partial` (a pickup, or a bar
|
|
117
|
+
* split by a repeat barline) is only as long as its `\partial` value. Honoring `\partial` makes
|
|
118
|
+
* the lilylet bar length match the corresponding MIDI measure's tick span (MuseScore emits a
|
|
119
|
+
* short 1/8 or 3/8 measure for exactly these), so the two onset grids align.
|
|
120
|
+
*/
|
|
121
|
+
export const measureOnsets = (doc: any): MeasureOnsets[] => {
|
|
122
|
+
let curTime: any = null;
|
|
123
|
+
const clefByVoice: { shift: number }[] = [];
|
|
124
|
+
return (doc.measures || []).map((m: any, mi: number): MeasureOnsets => {
|
|
125
|
+
if (m.timeSig) curTime = m.timeSig;
|
|
126
|
+
const notes: NoteOnset[] = [];
|
|
127
|
+
let maxCursor = 0;
|
|
128
|
+
let vIndex = 0;
|
|
129
|
+
// a \partial anywhere in the measure (any voice's leading contextChange) shortens the bar.
|
|
130
|
+
let partial: any = null;
|
|
131
|
+
for (const part of m.parts || []) {
|
|
132
|
+
for (const voice of part.voices || []) {
|
|
133
|
+
if (!clefByVoice[vIndex]) clefByVoice[vIndex] = { shift: 0 };
|
|
134
|
+
for (const ev of voice.events || []) {
|
|
135
|
+
if (ev.type === "context" && ev.partial) { partial = ev.partial; break; }
|
|
136
|
+
if (ev.type === "note" || ev.type === "rest" || ev.type === "tuplet"
|
|
137
|
+
|| ev.type === "times" || ev.type === "tremolo") break; // partial must precede notes
|
|
138
|
+
}
|
|
139
|
+
const end = walkVoice(voice.events || [], voice.staff ?? 1, vIndex, 1, 0, clefByVoice[vIndex], notes);
|
|
140
|
+
maxCursor = Math.max(maxCursor, end);
|
|
141
|
+
vIndex += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// bar length: \partial value if the bar is incomplete, else the full time-signature bar,
|
|
145
|
+
// else (no time sig) the longest voice cursor.
|
|
146
|
+
const fullBar = curTime ? RES * 4 * curTime.numerator / curTime.denominator : maxCursor;
|
|
147
|
+
const barDiv = partial ? calculateDuration(partial, RES) : fullBar;
|
|
148
|
+
const span = barDiv > 0 ? barDiv : (maxCursor > 0 ? maxCursor : 1);
|
|
149
|
+
for (const n of notes)
|
|
150
|
+
n.onsetNorm = n.onset / span;
|
|
151
|
+
return {
|
|
152
|
+
index: mi + 1,
|
|
153
|
+
timeSig: curTime ? { numerator: curTime.numerator, denominator: curTime.denominator } : null,
|
|
154
|
+
measureDivisions: span,
|
|
155
|
+
notes,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
};
|