@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Generic highlight scopes Lilylet tokens can carry. */
|
|
2
|
-
export type HighlightScope = "articulation" | "bar" | "brace" | "chordBracket" | "comment" | "dynamic" | "grace" | "hairpin" | "header" | "keyword" | "markup" | "mode" | "navigation" | "number" | "octave" | "operator" | "ornament" | "paren" | "pedal" | "pitch" | "punctuation" | "rest" | "separator" | "squareBracket" | "stem" | "string" | "tie" | "tuplet";
|
|
2
|
+
export type HighlightScope = "articulation" | "bar" | "brace" | "chordBracket" | "comment" | "dynamic" | "grace" | "hairpin" | "header" | "keyword" | "lyric" | "markup" | "mode" | "navigation" | "number" | "octave" | "operator" | "ornament" | "paren" | "pedal" | "pitch" | "punctuation" | "rest" | "separator" | "squareBracket" | "stem" | "string" | "tie" | "tuplet";
|
|
3
3
|
export interface HighlightRule {
|
|
4
4
|
/** Sticky, case-insensitive regex anchored at the scan position. */
|
|
5
5
|
re: RegExp;
|
package/lib/lilylet/highlight.js
CHANGED
|
@@ -50,6 +50,7 @@ export const HIGHLIGHT_RULES = [
|
|
|
50
50
|
{ re: /(?:\\segno\b)/iy, scope: "navigation" },
|
|
51
51
|
{ re: /(?:\\chords\b)/iy, scope: "keyword" },
|
|
52
52
|
{ re: /(?:\\markup\b)/iy, scope: "markup" },
|
|
53
|
+
{ re: /(?:\\addlyrics\b)/iy, scope: "lyric" },
|
|
53
54
|
{ re: /(?:\\<)/iy, scope: "hairpin" },
|
|
54
55
|
{ re: /(?:\\>)/iy, scope: "hairpin" },
|
|
55
56
|
{ re: /(?:\\!)/iy, scope: "hairpin" },
|
|
@@ -109,6 +110,13 @@ export const HIGHLIGHT_RULES = [
|
|
|
109
110
|
{ re: /(?:=)/iy, scope: "operator" },
|
|
110
111
|
{ re: /(?:[rR])/iy, scope: "rest" },
|
|
111
112
|
{ re: /(?:[sS])/iy, scope: "rest" },
|
|
113
|
+
{ re: /(?:\{)/iy, scope: "brace" },
|
|
114
|
+
{ re: /(?:\})/iy, scope: "brace" },
|
|
115
|
+
{ re: /(?:--)/iy, scope: "lyric" },
|
|
116
|
+
{ re: /(?:__\b)/iy, scope: "lyric" },
|
|
117
|
+
{ re: /(?:_\b)/iy, scope: "lyric" },
|
|
118
|
+
{ re: /(?:"([^\"\\]|\\.)*")/iy, scope: "string" },
|
|
119
|
+
{ re: /(?:[^ \t\r\n{}\"\\]+)/iy, scope: "string" },
|
|
112
120
|
];
|
|
113
121
|
/**
|
|
114
122
|
* Match a single token at `pos` in `line` using longest-match. Returns the
|
package/lib/lilylet/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./parser";
|
|
|
3
3
|
export * from "./serializer";
|
|
4
4
|
export * from "./staffLayout";
|
|
5
5
|
export * from "./measureLayout";
|
|
6
|
+
export * from "./onsets";
|
|
6
7
|
import * as meiEncoder from "./meiEncoder";
|
|
7
8
|
import * as musicXmlDecoder from "./musicXmlDecoder";
|
|
8
9
|
import * as lilypondEncoder from "./lilypondEncoder";
|
package/lib/lilylet/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./parser.js";
|
|
|
3
3
|
export * from "./serializer.js";
|
|
4
4
|
export * from "./staffLayout.js";
|
|
5
5
|
export * from "./measureLayout.js";
|
|
6
|
+
export * from "./onsets.js";
|
|
6
7
|
import * as meiEncoder from "./meiEncoder.js";
|
|
7
8
|
import * as musicXmlDecoder from "./musicXmlDecoder.js";
|
|
8
9
|
import * as lilypondEncoder from "./lilypondEncoder.js";
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* repeat/volta/segno-jump. Compaction is best-effort; the flat form is the
|
|
20
20
|
* always-valid fallback.
|
|
21
21
|
*/
|
|
22
|
-
import { parseMeasureLayout, expandMeasureLayout } from "./measureLayout.js";
|
|
22
|
+
import { parseMeasureLayout, expandMeasureLayout, LayoutType } from "./measureLayout.js";
|
|
23
23
|
import { getDirectChildren, getAttribute } from "./musicXmlUtils.js";
|
|
24
24
|
// Collapse runs of ≥3 consecutive ascending indices into "a..b"; emit the rest
|
|
25
25
|
// comma-separated. Mirrors lotus seqToCode for a flat index list.
|
|
@@ -62,14 +62,9 @@ const simulate = (infos) => {
|
|
|
62
62
|
byIndex.set(info.index, info);
|
|
63
63
|
const segnoIdx = infos.find(i => i.segno)?.index ?? 1;
|
|
64
64
|
const codaIdx = infos.find(i => i.coda)?.index;
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
const endingSpans = infos
|
|
69
|
-
.filter(i => i.endingStart !== undefined)
|
|
70
|
-
.map(i => ({ number: i.endingStart, start: i.index }))
|
|
71
|
-
.sort((a, b) => a.start - b.start);
|
|
72
|
-
const nextEndingStartAfter = (idx) => endingSpans.find(e => e.start > idx)?.start;
|
|
65
|
+
// Classify repeat/volta sections once so ending-skip decisions are scoped to
|
|
66
|
+
// the current repeat section, not a later independent section's endings.
|
|
67
|
+
const sections = classifyRepeatSections(infos, 1, n);
|
|
73
68
|
const order = [];
|
|
74
69
|
const passCount = new Map(); // forward-repeat start index → completed passes
|
|
75
70
|
let repeatStartStack = [1]; // implicit start at measure 1
|
|
@@ -87,15 +82,16 @@ const simulate = (infos) => {
|
|
|
87
82
|
if (info?.endingStart !== undefined) {
|
|
88
83
|
const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
|
|
89
84
|
const currentPass = (passCount.get(start) ?? 0) + 1;
|
|
85
|
+
const section = findSectionForIndex(sections, i);
|
|
86
|
+
const endingSpans = section ? endingSpansForSection(infos, section) : [];
|
|
90
87
|
const isLastEnding = endingSpans[endingSpans.length - 1]?.start === i;
|
|
91
88
|
if (info.endingStart !== currentPass && !isLastEnding) {
|
|
92
|
-
const next =
|
|
89
|
+
const next = endingSpans.find(e => e.start > i)?.start;
|
|
93
90
|
if (next !== undefined) {
|
|
94
91
|
i = next;
|
|
95
92
|
continue;
|
|
96
93
|
}
|
|
97
|
-
|
|
98
|
-
i = i + 1;
|
|
94
|
+
i = section ? section.hi + 1 : i + 1;
|
|
99
95
|
continue;
|
|
100
96
|
}
|
|
101
97
|
}
|
|
@@ -108,7 +104,10 @@ const simulate = (infos) => {
|
|
|
108
104
|
// Fine stop (only after a D.C./D.S. jump has occurred)
|
|
109
105
|
if (info?.fine && (jumpedDaCapo || jumpedDalSegno))
|
|
110
106
|
break;
|
|
111
|
-
// Backward repeat → jump to current repeat-start, counting the pass
|
|
107
|
+
// Backward repeat → jump to current repeat-start, counting the pass.
|
|
108
|
+
// If this same measure ALSO carries a D.C./D.S. (e.g. ABC "!D.C.!:|"), the
|
|
109
|
+
// repeat is resolved FIRST (all passes), then we fall through to the
|
|
110
|
+
// navigation jump below — the da-capo wraps the repeat, not the reverse.
|
|
112
111
|
if (info?.repeatEnd) {
|
|
113
112
|
const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
|
|
114
113
|
const times = info.repeatTimes ?? 2;
|
|
@@ -119,8 +118,13 @@ const simulate = (infos) => {
|
|
|
119
118
|
continue;
|
|
120
119
|
}
|
|
121
120
|
repeatStartStack = repeatStartStack.filter(s => s !== start);
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// repeat exhausted: only advance past it when there is no pending
|
|
122
|
+
// navigation on this measure (otherwise fall through to D.C./D.S.).
|
|
123
|
+
const hasPendingNav = (info.dacapo && !jumpedDaCapo) || (info.dalsegno && !jumpedDalSegno);
|
|
124
|
+
if (!hasPendingNav) {
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
124
128
|
}
|
|
125
129
|
// D.S. (dal segno) — jump back to segno once, arm To-Coda
|
|
126
130
|
if (info?.dalsegno && !jumpedDalSegno) {
|
|
@@ -146,6 +150,108 @@ const simulate = (infos) => {
|
|
|
146
150
|
// "pre, T*[body]{[e1…], [e2…]}, post". Anything more tangled (nested repeats,
|
|
147
151
|
// D.C./D.S. over a volta, segno jumps) is left to the flat fallback.
|
|
148
152
|
const rangeCode = (a, b) => a === b ? `${a}` : flatToCode(Array.from({ length: b - a + 1 }, (_, k) => a + k));
|
|
153
|
+
const isAscendingRun = (seq) => seq.every((v, i) => i === 0 || v === seq[i - 1] + 1);
|
|
154
|
+
const compactFlatToCode = (seq) => {
|
|
155
|
+
const parts = [];
|
|
156
|
+
let plain = [];
|
|
157
|
+
const flushPlain = () => {
|
|
158
|
+
if (plain.length > 0) {
|
|
159
|
+
parts.push(flatToCode(plain));
|
|
160
|
+
plain = [];
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
let i = 0;
|
|
164
|
+
while (i < seq.length) {
|
|
165
|
+
let repeatLen = 0;
|
|
166
|
+
for (let len = Math.floor((seq.length - i) / 2); len >= 1; len--) {
|
|
167
|
+
const chunk = seq.slice(i, i + len);
|
|
168
|
+
if (!isAscendingRun(chunk))
|
|
169
|
+
continue;
|
|
170
|
+
const next = seq.slice(i + len, i + 2 * len);
|
|
171
|
+
if (JSON.stringify(chunk) === JSON.stringify(next)) {
|
|
172
|
+
repeatLen = len;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (repeatLen > 0) {
|
|
177
|
+
flushPlain();
|
|
178
|
+
parts.push(`2*[${flatToCode(seq.slice(i, i + repeatLen))}]`);
|
|
179
|
+
i += repeatLen * 2;
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
plain.push(seq[i++]);
|
|
183
|
+
}
|
|
184
|
+
flushPlain();
|
|
185
|
+
return parts.join(", ");
|
|
186
|
+
};
|
|
187
|
+
const layoutExpandsTo = (code, seq, type = LayoutType.Full) => {
|
|
188
|
+
try {
|
|
189
|
+
const got = expandMeasureLayout(parseMeasureLayout(code), type);
|
|
190
|
+
return JSON.stringify(got) === JSON.stringify(seq);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const classifyRepeatSections = (infos, lo, hi) => {
|
|
197
|
+
const inSpan = (idx) => idx >= lo && idx <= hi;
|
|
198
|
+
const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
199
|
+
const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
200
|
+
const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
201
|
+
if (repeatEnds.length === 0) {
|
|
202
|
+
return [{ lo, hi, startIdx: lo, times: 1, endingStarts: [], spurious: false }];
|
|
203
|
+
}
|
|
204
|
+
const sections = [];
|
|
205
|
+
let prevHi = lo - 1;
|
|
206
|
+
for (const e of repeatEnds) {
|
|
207
|
+
if (e <= prevHi)
|
|
208
|
+
continue;
|
|
209
|
+
const sectionLo = prevHi + 1;
|
|
210
|
+
const nextStart = repeatStarts.find(s => s > e);
|
|
211
|
+
const sectionEndingStarts = infos
|
|
212
|
+
.filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
|
|
213
|
+
.map(i => i.index)
|
|
214
|
+
.sort((a, b) => a - b);
|
|
215
|
+
let sectionHi;
|
|
216
|
+
if (repeatEnds.length === 1)
|
|
217
|
+
sectionHi = hi;
|
|
218
|
+
else if (sectionEndingStarts.length) {
|
|
219
|
+
const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
|
|
220
|
+
const stop = endingStops.find(s => s >= lastStart);
|
|
221
|
+
sectionHi = stop ?? lastStart;
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
sectionHi = e;
|
|
225
|
+
const explicitStartInSection = repeatStarts.some(s => s >= sectionLo && s <= e);
|
|
226
|
+
const implicitFromStart = sectionLo === lo && lo === 1;
|
|
227
|
+
const spurious = sectionEndingStarts.length === 0 && !explicitStartInSection && !implicitFromStart;
|
|
228
|
+
const startIdx = repeatStarts.filter(s => s >= sectionLo && s <= e).sort((a, b) => b - a)[0] ?? sectionLo;
|
|
229
|
+
const endInfo = infos.find(i => i.index === e);
|
|
230
|
+
sections.push({ lo: sectionLo, hi: sectionHi, startIdx, endIdx: e, times: endInfo?.repeatTimes ?? 2, endingStarts: sectionEndingStarts, spurious });
|
|
231
|
+
prevHi = sectionHi;
|
|
232
|
+
}
|
|
233
|
+
if (prevHi < hi)
|
|
234
|
+
sections.push({ lo: prevHi + 1, hi, startIdx: prevHi + 1, times: 1, endingStarts: [], spurious: false });
|
|
235
|
+
return sections;
|
|
236
|
+
};
|
|
237
|
+
const findSectionForIndex = (sections, idx) => sections.find(s => idx >= s.lo && idx <= s.hi);
|
|
238
|
+
const endingSpansForSection = (infos, section) => {
|
|
239
|
+
const spans = [];
|
|
240
|
+
for (let e = 0; e < section.endingStarts.length; e++) {
|
|
241
|
+
const start = section.endingStarts[e];
|
|
242
|
+
const nextStart = section.endingStarts[e + 1];
|
|
243
|
+
let stop;
|
|
244
|
+
if (nextStart !== undefined)
|
|
245
|
+
stop = nextStart - 1;
|
|
246
|
+
else {
|
|
247
|
+
const stopInfo = infos.find(info => info.index >= start && info.index <= section.hi && info.endingStop !== undefined);
|
|
248
|
+
stop = stopInfo ? stopInfo.index : start;
|
|
249
|
+
}
|
|
250
|
+
const number = infos.find(info => info.index === start)?.endingStart ?? e + 1;
|
|
251
|
+
spans.push({ number, start, stop });
|
|
252
|
+
}
|
|
253
|
+
return spans;
|
|
254
|
+
};
|
|
149
255
|
// Render the repeat/volta structure over measures [lo..hi] (no navigation),
|
|
150
256
|
// or null if it isn't a single clean repeat/volta span. Shared by the plain
|
|
151
257
|
// path and the da-capo ABA wrapper.
|
|
@@ -220,62 +326,39 @@ const renderRepeatSpan = (infos, lo, hi) => {
|
|
|
220
326
|
// Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
|
|
221
327
|
// halves of a da-capo ABA.
|
|
222
328
|
const renderRepeatSections = (infos, lo, hi) => {
|
|
223
|
-
const
|
|
224
|
-
const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
225
|
-
const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
226
|
-
if (repeatEnds.length <= 1)
|
|
227
|
-
return renderRepeatSpan(infos, lo, hi);
|
|
228
|
-
const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
|
|
329
|
+
const sections = classifyRepeatSections(infos, lo, hi);
|
|
229
330
|
const parts = [];
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// endings sit AFTER the repeat-end but BEFORE the next section starts).
|
|
235
|
-
const nextStart = repeatStarts.find(s => s > e);
|
|
236
|
-
const sectionEndingStarts = infos
|
|
237
|
-
.filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
|
|
238
|
-
.map(i => i.index)
|
|
239
|
-
.sort((a, b) => a - b);
|
|
240
|
-
let sectionHi;
|
|
241
|
-
if (sectionEndingStarts.length) {
|
|
242
|
-
const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
|
|
243
|
-
// the last ending's stop closes the section; else the ending start itself
|
|
244
|
-
const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
|
|
245
|
-
sectionHi = stop ?? lastStart;
|
|
331
|
+
for (const section of sections) {
|
|
332
|
+
if (section.spurious) {
|
|
333
|
+
parts.push(rangeCode(section.lo, section.hi));
|
|
334
|
+
continue;
|
|
246
335
|
}
|
|
247
|
-
|
|
248
|
-
sectionHi = e;
|
|
249
|
-
const code = renderRepeatSpan(infos, sectionLo, sectionHi);
|
|
336
|
+
const code = renderRepeatSpan(infos, section.lo, section.hi);
|
|
250
337
|
if (code === null)
|
|
251
338
|
return null;
|
|
252
339
|
parts.push(code);
|
|
253
|
-
prevHi = sectionHi;
|
|
254
340
|
}
|
|
255
|
-
// trailing non-repeated tail
|
|
256
|
-
if (prevHi < hi)
|
|
257
|
-
parts.push(rangeCode(prevHi + 1, hi));
|
|
258
341
|
return parts.join(", ");
|
|
259
342
|
};
|
|
260
|
-
const tryStructured = (infos, total) => {
|
|
343
|
+
const tryStructured = (infos, total, performed) => {
|
|
261
344
|
const dacapo = infos.find(i => i.dacapo);
|
|
262
345
|
const dalsegno = infos.some(i => i.dalsegno);
|
|
263
346
|
const tocoda = infos.some(i => i.tocoda);
|
|
264
|
-
// D.C.
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
347
|
+
// D.C. / D.S. jumps that replay from the start can use the ABA form. A real
|
|
348
|
+
// Segno (not measure 1) and To-Coda still fall back to flat because the DSL has
|
|
349
|
+
// no "replay from arbitrary middle marker" construct. Bare Fine/Segno/Coda marks
|
|
350
|
+
// without a jump are visual landmarks only; they must not block repeat structuring.
|
|
351
|
+
const hasExplicitSegno = infos.some(i => i.segno);
|
|
352
|
+
if (!tocoda && dacapo)
|
|
353
|
+
return buildDaCapoABA(infos, dacapo.index, performed);
|
|
354
|
+
if (!tocoda && dalsegno && !hasExplicitSegno) {
|
|
355
|
+
const ds = infos.find(i => i.dalsegno);
|
|
356
|
+
if (ds)
|
|
357
|
+
return buildDaCapoABA(infos, ds.index, performed);
|
|
275
358
|
}
|
|
276
|
-
if (dalsegno || tocoda
|
|
359
|
+
if (dalsegno || tocoda)
|
|
277
360
|
return null;
|
|
278
|
-
// No navigation: one or more plain repeat/volta sections in sequence. Most
|
|
361
|
+
// No playback-changing navigation: one or more plain repeat/volta sections in sequence. Most
|
|
279
362
|
// real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
|
|
280
363
|
// render each independently and join with commas.
|
|
281
364
|
return renderRepeatSections(infos, 1, total);
|
|
@@ -289,7 +372,7 @@ const tryStructured = (infos, total) => {
|
|
|
289
372
|
// via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
|
|
290
373
|
// bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
|
|
291
374
|
// re-expand check still validates the result against the simulated order.
|
|
292
|
-
const buildDaCapoABA = (infos, dcIdx) => {
|
|
375
|
+
const buildDaCapoABA = (infos, dcIdx, performed) => {
|
|
293
376
|
const fineIdx = infos.find(i => i.fine)?.index;
|
|
294
377
|
if (fineIdx === undefined) {
|
|
295
378
|
// No explicit Fine: fall back to the legacy single-volta ABA shape, where
|
|
@@ -297,14 +380,12 @@ const buildDaCapoABA = (infos, dcIdx) => {
|
|
|
297
380
|
return buildDaCapoABALegacy(infos, dcIdx);
|
|
298
381
|
}
|
|
299
382
|
if (fineIdx >= dcIdx)
|
|
300
|
-
return null; // Fine must precede the D.C. tail
|
|
383
|
+
return null; // Fine must precede the D.C./D.S. tail
|
|
301
384
|
const restLo = fineIdx + 1;
|
|
302
|
-
if (restLo > dcIdx)
|
|
303
|
-
return null; // no B section
|
|
304
385
|
const mainCode = renderRepeatSections(infos, 1, fineIdx);
|
|
305
|
-
const restCode = renderRepeatSections(infos, restLo, dcIdx);
|
|
386
|
+
const restCode = restLo > dcIdx ? "" : renderRepeatSections(infos, restLo, dcIdx);
|
|
306
387
|
if (mainCode === null || restCode === null)
|
|
307
|
-
return
|
|
388
|
+
return buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
|
|
308
389
|
// `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
|
|
309
390
|
// in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
|
|
310
391
|
// which parseItem stops at the first number). A lone number, or a single
|
|
@@ -314,7 +395,39 @@ const buildDaCapoABA = (infos, dcIdx) => {
|
|
|
314
395
|
const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
|
|
315
396
|
const main = isSingleToken ? mainCode : `[${mainCode}]`;
|
|
316
397
|
const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
|
|
317
|
-
|
|
398
|
+
const candidate = `<${main}, ${rest}>`;
|
|
399
|
+
return layoutExpandsTo(candidate, performed) ? candidate : buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
|
|
400
|
+
};
|
|
401
|
+
const wrapAbaMain = (code) => /^\d+$/.test(code) || /^\d+\*\[/.test(code) || /^\[/.test(code) ? code : `[${code}]`;
|
|
402
|
+
const wrapAbaRest = (code) => code === "" ? "" : (/,/.test(code) ? `[${code}]` : code);
|
|
403
|
+
const buildDaCapoABAFromReplay = (infos, dcIdx, fineIdx, performed) => {
|
|
404
|
+
// Some ABC/MusicXML D.C. exports place repeat-end/start boundaries so the
|
|
405
|
+
// notated pre-Fine span (1..Fine) is not enough to render main via source flags,
|
|
406
|
+
// even though the simulated order is a clean ABA: A(full) B A(once). Detect that
|
|
407
|
+
// shape from the final da-capo replay, then reuse the section renderer for the
|
|
408
|
+
// longer A candidate and derive B from the performed middle slice.
|
|
409
|
+
for (let k = Math.min(fineIdx, performed.length - 1); k >= 1; k--) {
|
|
410
|
+
const replay = Array.from({ length: k }, (_, i) => i + 1);
|
|
411
|
+
if (JSON.stringify(performed.slice(-k)) !== JSON.stringify(replay))
|
|
412
|
+
continue;
|
|
413
|
+
for (let mainHi = k; mainHi < dcIdx; mainHi++) {
|
|
414
|
+
const mainCode = renderRepeatSections(infos, 1, mainHi);
|
|
415
|
+
if (mainCode === null)
|
|
416
|
+
continue;
|
|
417
|
+
const mainFull = expandMeasureLayout(parseMeasureLayout(mainCode));
|
|
418
|
+
const mainOnce = expandMeasureLayout(parseMeasureLayout(mainCode), LayoutType.Once);
|
|
419
|
+
if (JSON.stringify(mainOnce) !== JSON.stringify(replay))
|
|
420
|
+
continue;
|
|
421
|
+
if (JSON.stringify(performed.slice(0, mainFull.length)) !== JSON.stringify(mainFull))
|
|
422
|
+
continue;
|
|
423
|
+
const restSeq = performed.slice(mainFull.length, performed.length - mainOnce.length);
|
|
424
|
+
const restCode = compactFlatToCode(restSeq);
|
|
425
|
+
const candidate = `<${wrapAbaMain(mainCode)}, ${wrapAbaRest(restCode)}>`;
|
|
426
|
+
if (layoutExpandsTo(candidate, performed))
|
|
427
|
+
return candidate;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
318
431
|
};
|
|
319
432
|
// Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
|
|
320
433
|
// volta/repeat structure before the D.C., rest = the post-volta tail. Kept for
|
|
@@ -348,10 +461,9 @@ const buildDaCapoABALegacy = (infos, dcIdx) => {
|
|
|
348
461
|
const voltaCode = `${times}*[${rangeCode(startIdx, bodyEnd)}]{${altCode}}`;
|
|
349
462
|
const pre = startIdx > 1 ? rangeCode(1, startIdx - 1) + ", " : "";
|
|
350
463
|
const main = pre ? `[${pre}${voltaCode}]` : voltaCode;
|
|
351
|
-
// rest = the tail from after the last ending to the D.C. measure
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const rest = rangeCode(lastStop + 1, dcIdx);
|
|
464
|
+
// rest = the tail from after the last ending to the D.C. measure. It may be
|
|
465
|
+
// empty when the navigation jump follows immediately after the main repeat.
|
|
466
|
+
const rest = lastStop >= dcIdx ? "" : rangeCode(lastStop + 1, dcIdx);
|
|
355
467
|
return `<${main}, ${rest}>`;
|
|
356
468
|
};
|
|
357
469
|
/**
|
|
@@ -370,7 +482,7 @@ export const buildMeasureLayout = (infos, totalMeasures) => {
|
|
|
370
482
|
// No actual unfolding happened (e.g. a lone start-repeat with no end): skip.
|
|
371
483
|
if (performed.length === totalMeasures && performed.every((v, k) => v === k + 1))
|
|
372
484
|
return undefined;
|
|
373
|
-
const structured = tryStructured(infos, totalMeasures);
|
|
485
|
+
const structured = tryStructured(infos, totalMeasures, performed);
|
|
374
486
|
if (structured) {
|
|
375
487
|
// Trust the structured form only if it reproduces the simulated order.
|
|
376
488
|
try {
|
|
@@ -380,6 +492,9 @@ export const buildMeasureLayout = (infos, totalMeasures) => {
|
|
|
380
492
|
}
|
|
381
493
|
catch { /* fall through to flat */ }
|
|
382
494
|
}
|
|
495
|
+
const compacted = compactFlatToCode(performed);
|
|
496
|
+
if (compacted !== flatToCode(performed) && layoutExpandsTo(compacted, performed))
|
|
497
|
+
return compacted;
|
|
383
498
|
return flatToCode(performed);
|
|
384
499
|
};
|
|
385
500
|
// Recognize navigation semantics from a <words> string (case-insensitive).
|
|
@@ -266,6 +266,42 @@ const tremoloToStemMod = (division) => {
|
|
|
266
266
|
}
|
|
267
267
|
return undefined;
|
|
268
268
|
};
|
|
269
|
+
const lyricWordPosition = (syllables, index) => {
|
|
270
|
+
const syllable = syllables[index];
|
|
271
|
+
if (!syllable?.text)
|
|
272
|
+
return 'u';
|
|
273
|
+
const previous = index > 0 ? syllables[index - 1] : undefined;
|
|
274
|
+
if (previous?.text && previous.hyphen)
|
|
275
|
+
return syllable.hyphen ? 'm' : 't';
|
|
276
|
+
return syllable.hyphen ? 'i' : 'u';
|
|
277
|
+
};
|
|
278
|
+
const lyricSyllableXml = (lane, index, indent) => {
|
|
279
|
+
const syllable = lane.syllables[index];
|
|
280
|
+
if (!syllable || syllable.skip || syllable.text === undefined)
|
|
281
|
+
return '';
|
|
282
|
+
const wordpos = lyricWordPosition(lane.syllables, index);
|
|
283
|
+
const con = syllable.extend ? 'u' : syllable.hyphen ? 'd' : undefined;
|
|
284
|
+
const attrs = ` wordpos="${wordpos}"${con ? ` con="${con}"` : ''}`;
|
|
285
|
+
return `${indent}<syl${attrs}>${escapeXml(syllable.text)}</syl>\n`;
|
|
286
|
+
};
|
|
287
|
+
const makeLyricCursors = (lanes, indent) => (lanes || []).map((lane) => {
|
|
288
|
+
let index = 0;
|
|
289
|
+
const consume = () => {
|
|
290
|
+
const current = index++;
|
|
291
|
+
const syllable = lane.syllables[current];
|
|
292
|
+
if (!syllable || syllable.skip || syllable.text === undefined)
|
|
293
|
+
return '';
|
|
294
|
+
const verse = lane.verse || 1;
|
|
295
|
+
return `${indent}<verse n="${verse}">\n${lyricSyllableXml(lane, current, indent + ' ')}${indent}</verse>\n`;
|
|
296
|
+
};
|
|
297
|
+
return { consume, consumeRest: () => { index++; } };
|
|
298
|
+
});
|
|
299
|
+
const lyricXmlForEvent = (cursors, indent) => {
|
|
300
|
+
let xml = '';
|
|
301
|
+
for (const cursor of cursors)
|
|
302
|
+
xml += cursor.consume();
|
|
303
|
+
return xml;
|
|
304
|
+
};
|
|
269
305
|
// Build note element
|
|
270
306
|
const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteId) => {
|
|
271
307
|
const id = noteId || generateId('note');
|
|
@@ -295,8 +331,7 @@ const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteI
|
|
|
295
331
|
if (stemMod)
|
|
296
332
|
attrs += ` stem.mod="${stemMod}"`;
|
|
297
333
|
}
|
|
298
|
-
|
|
299
|
-
const hasChildren = !inChord && options.artics && options.artics.length > 0;
|
|
334
|
+
const hasChildren = !inChord && ((options.artics && options.artics.length > 0) || !!options.lyricXml);
|
|
300
335
|
if (!hasChildren) {
|
|
301
336
|
return `${indent}<note ${attrs} />\n`;
|
|
302
337
|
}
|
|
@@ -316,6 +351,8 @@ const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteI
|
|
|
316
351
|
result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
|
|
317
352
|
}
|
|
318
353
|
}
|
|
354
|
+
if (options.lyricXml)
|
|
355
|
+
result += options.lyricXml;
|
|
319
356
|
result += `${indent}</note>\n`;
|
|
320
357
|
return result;
|
|
321
358
|
};
|
|
@@ -466,7 +503,7 @@ const extractMarkOptions = (marks) => {
|
|
|
466
503
|
return result;
|
|
467
504
|
};
|
|
468
505
|
// Convert NoteEvent to MEI
|
|
469
|
-
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
506
|
+
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals, lyricXml = '') => {
|
|
470
507
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
471
508
|
const dots = event.duration.dots || 0;
|
|
472
509
|
const markOptions = extractMarkOptions(event.marks);
|
|
@@ -503,7 +540,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
503
540
|
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
|
|
504
541
|
const noteId = generateId('note');
|
|
505
542
|
return {
|
|
506
|
-
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
543
|
+
xml: buildNoteElement(pitch, dur, dots, indent, false, { ...noteOptions, lyricXml }, noteId),
|
|
507
544
|
elementId: noteId,
|
|
508
545
|
hairpin: markOptions.hairpin,
|
|
509
546
|
pedal: markOptions.pedal,
|
|
@@ -565,6 +602,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
565
602
|
result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
|
|
566
603
|
}
|
|
567
604
|
}
|
|
605
|
+
if (lyricXml)
|
|
606
|
+
result += lyricXml;
|
|
568
607
|
result += `${indent}</chord>\n`;
|
|
569
608
|
return {
|
|
570
609
|
xml: result,
|
|
@@ -642,7 +681,7 @@ const tupletHasInternalBeams = (event) => {
|
|
|
642
681
|
return starts > 0 && starts === ends;
|
|
643
682
|
};
|
|
644
683
|
// Convert TupletEvent to MEI
|
|
645
|
-
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef) => {
|
|
684
|
+
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef, lyricCursors = []) => {
|
|
646
685
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
647
686
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
648
687
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -685,7 +724,8 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
685
724
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
686
725
|
? { ...noteEvent, staff: effectiveStaff }
|
|
687
726
|
: noteEvent;
|
|
688
|
-
const
|
|
727
|
+
const lyricXml = lyricCursors.length ? lyricXmlForEvent(lyricCursors, noteIndent + ' ') : '';
|
|
728
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals, lyricXml);
|
|
689
729
|
xml += result.xml;
|
|
690
730
|
if (!firstNoteId)
|
|
691
731
|
firstNoteId = result.elementId;
|
|
@@ -863,6 +903,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
863
903
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
864
904
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
865
905
|
const baseIndent = indent + ' ';
|
|
906
|
+
const lyricCursors = makeLyricCursors(voice.lyrics, baseIndent);
|
|
866
907
|
// Track current clef to only emit changes
|
|
867
908
|
let currentClef = initialClef;
|
|
868
909
|
// Track hairpin spans. Use a stack of open hairpins (not a single slot): the
|
|
@@ -1021,7 +1062,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1021
1062
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
1022
1063
|
? { ...noteEvent, staff: currentStaff }
|
|
1023
1064
|
: noteEvent;
|
|
1024
|
-
const
|
|
1065
|
+
const eventLyricXml = isGraceNote ? '' : lyricXmlForEvent(lyricCursors, currentIndent + ' ');
|
|
1066
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals, eventLyricXml);
|
|
1025
1067
|
xml += result.xml;
|
|
1026
1068
|
lastNoteId = result.elementId;
|
|
1027
1069
|
extendOttavaEnd(result.elementId);
|
|
@@ -1166,7 +1208,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1166
1208
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1167
1209
|
// A pending ottava shift applies to the tuplet's notes' written octave.
|
|
1168
1210
|
applyPendingOttavaShift();
|
|
1169
|
-
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1211
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef, lyricCursors);
|
|
1170
1212
|
xml += tupletResult.xml;
|
|
1171
1213
|
// An ottava span can open on, run through, or close at a tuplet (common
|
|
1172
1214
|
// in etudes where the 8va covers a whole tuplet run). Anchor the span on
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
* The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
|
|
13
|
+
* "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
|
|
14
|
+
* (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
|
|
15
|
+
* A plain clef (no suffix) declares 0.
|
|
16
|
+
*/
|
|
17
|
+
export declare const clefShift: (clefStr: string) => number;
|
|
18
|
+
/**
|
|
19
|
+
* Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C
|
|
20
|
+
* octave = MIDI 60 (see parser.resolveRelativePitch). `shift` is the active clef's
|
|
21
|
+
* written->sounding semitone transposition.
|
|
22
|
+
*/
|
|
23
|
+
export declare const pitchToMidi: (pitch: any, shift?: number) => number;
|
|
24
|
+
export interface NoteOnset {
|
|
25
|
+
onset: number;
|
|
26
|
+
onsetNorm: number;
|
|
27
|
+
durationDiv: number;
|
|
28
|
+
midi: number[];
|
|
29
|
+
staff: number;
|
|
30
|
+
voice: number;
|
|
31
|
+
grace: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface MeasureOnsets {
|
|
34
|
+
index: number;
|
|
35
|
+
timeSig: {
|
|
36
|
+
numerator: number;
|
|
37
|
+
denominator: number;
|
|
38
|
+
} | null;
|
|
39
|
+
measureDivisions: number;
|
|
40
|
+
notes: NoteOnset[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-measure note onsets for a parsed LilyletDoc. The active clef transposition is tracked
|
|
44
|
+
* per (positional) voice index and PERSISTS across measures — a \clef stays in force until the
|
|
45
|
+
* next one, so a "treble_8" set in measure 1 still lowers measure 2 by an octave.
|
|
46
|
+
*
|
|
47
|
+
* Measure length (`measureDivisions`, used to normalize onsets to [0,1)) comes from the time
|
|
48
|
+
* signature for a full bar, but an INCOMPLETE bar carrying a `\partial` (a pickup, or a bar
|
|
49
|
+
* split by a repeat barline) is only as long as its `\partial` value. Honoring `\partial` makes
|
|
50
|
+
* the lilylet bar length match the corresponding MIDI measure's tick span (MuseScore emits a
|
|
51
|
+
* short 1/8 or 3/8 measure for exactly these), so the two onset grids align.
|
|
52
|
+
*/
|
|
53
|
+
export declare const measureOnsets: (doc: any) => MeasureOnsets[];
|