@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
|
@@ -189,6 +189,70 @@ const convertDuration = (abcDuration, unitLength) => {
|
|
|
189
189
|
const log2 = Math.round(Math.log2(division));
|
|
190
190
|
return { division: Math.pow(2, Math.max(0, log2)), dots: 0 };
|
|
191
191
|
};
|
|
192
|
+
// A whole note is WHOLE_TICKS ticks. 2^7·3^3·5·7 = 120960 divides by every dot (·3/2, ·7/4),
|
|
193
|
+
// tuplet (÷3, ÷5, ÷7), and common division, so a voice's played length is an exact integer —
|
|
194
|
+
// no float rounding when we compare it to the bar length (unlike calculateDuration's Math.round).
|
|
195
|
+
const WHOLE_TICKS = 120960;
|
|
196
|
+
// A resolved Duration's tick length (dots + optional lilylet tuplet ratio num/den applied).
|
|
197
|
+
const durationTicks = (d) => {
|
|
198
|
+
let t = WHOLE_TICKS / d.division;
|
|
199
|
+
if (d.dots) {
|
|
200
|
+
let dot = t / 2;
|
|
201
|
+
for (let i = 0; i < d.dots; i++) {
|
|
202
|
+
t += dot;
|
|
203
|
+
dot /= 2;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (d.tuplet)
|
|
207
|
+
t = t * d.tuplet.numerator / d.tuplet.denominator;
|
|
208
|
+
return t;
|
|
209
|
+
};
|
|
210
|
+
// Sum the played tick length of an event list. Context / barline / markup / dynamic events
|
|
211
|
+
// take no time, and GRACE notes steal none (they carry a nominal duration but do not advance
|
|
212
|
+
// the beat). A tuplet/times group scales its inner notes by ratio num/den (e.g. \tuplet 3/2 of
|
|
213
|
+
// eighths = a quarter). This mirrors the parser's own \partial check so the emitted \partial
|
|
214
|
+
// never trips a partial-mismatch warning. Used to measure a voice against its bar length.
|
|
215
|
+
const eventsTicks = (events) => {
|
|
216
|
+
let total = 0;
|
|
217
|
+
for (const ev of events) {
|
|
218
|
+
if (ev.type === "note" || ev.type === "rest") {
|
|
219
|
+
if (ev.grace)
|
|
220
|
+
continue;
|
|
221
|
+
total += durationTicks(ev.duration);
|
|
222
|
+
}
|
|
223
|
+
else if (ev.type === "tuplet" || ev.type === "times") {
|
|
224
|
+
const t = ev;
|
|
225
|
+
const factor = t.ratio ? t.ratio.numerator / t.ratio.denominator : 1;
|
|
226
|
+
for (const inner of t.events || []) {
|
|
227
|
+
if ((inner.type === "note" || inner.type === "rest") && !inner.grace)
|
|
228
|
+
total += durationTicks(inner.duration) * factor;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (ev.type === "tremolo") {
|
|
232
|
+
const tr = ev;
|
|
233
|
+
total += 2 * (WHOLE_TICKS / tr.division);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return total;
|
|
237
|
+
};
|
|
238
|
+
// Build a \partial Duration (single {division, dots}) for a pickup of `ticks` whole-note ticks.
|
|
239
|
+
// LilyPond's \partial takes one duration token, so only power-of-2 divisions with up to 2 dots
|
|
240
|
+
// are expressible; a length that isn't (e.g. a 5/8 pickup) returns null and no \partial is added
|
|
241
|
+
// (the bar simply renders short) rather than emitting a wrong duration.
|
|
242
|
+
const ticksToPartialDuration = (ticks) => {
|
|
243
|
+
if (ticks <= 0)
|
|
244
|
+
return null;
|
|
245
|
+
for (let division = 1; division <= 128; division *= 2) {
|
|
246
|
+
const base = WHOLE_TICKS / division;
|
|
247
|
+
if (ticks === base)
|
|
248
|
+
return { division, dots: 0 };
|
|
249
|
+
if (ticks === base * 1.5)
|
|
250
|
+
return { division, dots: 1 };
|
|
251
|
+
if (ticks === base * 1.75)
|
|
252
|
+
return { division, dots: 2 };
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
};
|
|
192
256
|
/**
|
|
193
257
|
* Apply broken rhythm adjustment.
|
|
194
258
|
* broken > 0 (A>B): current note gets dotted, next gets halved
|
|
@@ -492,6 +556,12 @@ const collectAbcRepeatInfo = (bars) => {
|
|
|
492
556
|
// ("|:" forward, ":|:"/"::"/":||:" end-and-start).
|
|
493
557
|
const startsNext = trailBar.includes("|:") || isEndStart;
|
|
494
558
|
if (startsNext && idx < n) {
|
|
559
|
+
// A new repeat section opening here also CLOSES any still-open ending
|
|
560
|
+
// (a volta cannot span into the next repeat section). abc2xml back-patches
|
|
561
|
+
// the ending-stop onto this measure; without this a final volta (e.g.
|
|
562
|
+
// ":|2 …|:") would overshoot to the next volta/section bar far ahead.
|
|
563
|
+
if (openEndingStart >= 1 && openEndingStart <= idx)
|
|
564
|
+
closeOpenEnding(idx);
|
|
495
565
|
infos[idx].repeatStart = true;
|
|
496
566
|
}
|
|
497
567
|
// A trailing volta digit ("|2", ":|1,2") opens an ending on the NEXT measure.
|
|
@@ -660,6 +730,13 @@ const abcLayoutToStaves = (layout) => {
|
|
|
660
730
|
* `voiceInstr` maps an ABC voice number to its {name, shortName}. The layout's staff ids
|
|
661
731
|
* are arc-first voice numbers, so a staff id "5" looks up voice 5's name.
|
|
662
732
|
*/
|
|
733
|
+
// ABC quoted strings carry literal escape sequences (e.g. `nm="Violin\nsolo"` is the 11
|
|
734
|
+
// characters V-i-o-l-i-n-\-n-s-o-l-o). The lilylet doc model holds DECODED strings — the
|
|
735
|
+
// serializer's escapeString re-encodes a real newline back to `\n`. Without decoding here,
|
|
736
|
+
// the stored backslash would be escaped a second time (`\n` → `\\n`). Mirror the lilylet
|
|
737
|
+
// grammar's `unq` unescape table so both import paths agree.
|
|
738
|
+
const ABC_UNESCAPE = { n: "\n", r: "\r", t: "\t", '"': '"', "\\": "\\" };
|
|
739
|
+
const unescapeAbcString = (s) => s.replace(/\\(.)/g, (m, c) => (c in ABC_UNESCAPE ? ABC_UNESCAPE[c] : m));
|
|
663
740
|
const abcInstrumentsToLilylet = (stavesCode, voiceInstr) => {
|
|
664
741
|
const layout = parseStaffLayout(stavesCode);
|
|
665
742
|
const result = {};
|
|
@@ -1339,8 +1416,8 @@ const decodeTune = (tune, options = {}) => {
|
|
|
1339
1416
|
continue;
|
|
1340
1417
|
const short = props.snm ?? props.sname;
|
|
1341
1418
|
voiceInstr.set(v, typeof short === "string" && short.length
|
|
1342
|
-
? { name, shortName: short }
|
|
1343
|
-
: { name });
|
|
1419
|
+
? { name: unescapeAbcString(name), shortName: unescapeAbcString(short) }
|
|
1420
|
+
: { name: unescapeAbcString(name) });
|
|
1344
1421
|
}
|
|
1345
1422
|
if (voiceInstr.size > 0) {
|
|
1346
1423
|
const instruments = abcInstrumentsToLilylet(metadata.staves, voiceInstr);
|
|
@@ -1369,6 +1446,10 @@ const decodeTune = (tune, options = {}) => {
|
|
|
1369
1446
|
// index. Built alongside barInfos' navTexts; attached after each measure's
|
|
1370
1447
|
// parts are assembled (mirrors the MusicXML path attaching nav to a note).
|
|
1371
1448
|
const navMarksByMeasure = new Map();
|
|
1449
|
+
// Running effective time signature (seeded from the header M:, updated by any inline
|
|
1450
|
+
// [M:...] change). A bar whose played length is SHORTER than this gets a \partial (see
|
|
1451
|
+
// the pickup/anacrusis handling below the per-measure assembly).
|
|
1452
|
+
let curTime = timeSig;
|
|
1372
1453
|
for (let mi = 0; mi < measures.length; mi++) {
|
|
1373
1454
|
const abcMeasure = measures[mi];
|
|
1374
1455
|
// Group patches by voice number
|
|
@@ -1561,6 +1642,66 @@ const decodeTune = (tune, options = {}) => {
|
|
|
1561
1642
|
break;
|
|
1562
1643
|
}
|
|
1563
1644
|
}
|
|
1645
|
+
// Pickup / short bar → \partial. Update the running time signature from any inline
|
|
1646
|
+
// [M:...] this bar carried (the LAST \time in the bar wins — a bar may restate then
|
|
1647
|
+
// change meter, e.g. \time 3/4 \time 9/8), then compare the bar's played length (the
|
|
1648
|
+
// longest voice, so a bar that is complete in ANY voice is not treated as short)
|
|
1649
|
+
// against the meter's full-bar length. A strictly shorter bar is an anacrusis (bar 1)
|
|
1650
|
+
// or a truncated final bar; emit \partial <bar-length> on the first voice so LilyPond
|
|
1651
|
+
// renders the incomplete bar. A full-measure rest (Z/R) voice is skipped — it fills the
|
|
1652
|
+
// bar by definition, so it must not drag the measured length down.
|
|
1653
|
+
{
|
|
1654
|
+
for (const part of parts) {
|
|
1655
|
+
for (const voice of part.voices) {
|
|
1656
|
+
for (const e of voice.events) {
|
|
1657
|
+
if (e.type === "context" && e.time) {
|
|
1658
|
+
const t = e.time;
|
|
1659
|
+
curTime = { numerator: t.numerator, denominator: t.denominator };
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (curTime) {
|
|
1665
|
+
const barTicks = WHOLE_TICKS * curTime.numerator / curTime.denominator;
|
|
1666
|
+
// \partial is a GLOBAL timing directive — it shortens the bar for every voice at
|
|
1667
|
+
// once. So it is only correct when every sounding voice has the SAME short length.
|
|
1668
|
+
// Collect each voice's played ticks; a full-measure-rest (Z/R) voice fills the bar
|
|
1669
|
+
// and is ignored. If the voices are ragged (different lengths — e.g. a messy meter
|
|
1670
|
+
// change where some voices are 6/8 and others 8/8), no single \partial fits them
|
|
1671
|
+
// all, so we emit none rather than one that mis-sizes some voices.
|
|
1672
|
+
const lengths = [];
|
|
1673
|
+
let anyFull = false;
|
|
1674
|
+
for (const part of parts) {
|
|
1675
|
+
for (const voice of part.voices) {
|
|
1676
|
+
if (voice.events.some(e => e.type === "rest" && e.fullMeasure)) {
|
|
1677
|
+
anyFull = true;
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
const t = eventsTicks(voice.events);
|
|
1681
|
+
if (t > 0)
|
|
1682
|
+
lengths.push(t);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
const uniform = lengths.length > 0 && lengths.every(t => t === lengths[0]);
|
|
1686
|
+
const played = uniform ? lengths[0] : 0;
|
|
1687
|
+
if (!anyFull && uniform && played < barTicks) {
|
|
1688
|
+
const partial = ticksToPartialDuration(played);
|
|
1689
|
+
if (partial) {
|
|
1690
|
+
// Insert after any leading key/time context so the emitted order is
|
|
1691
|
+
// \key … \time … \partial … (LilyPond wants \partial after \time).
|
|
1692
|
+
const target = measure.parts[0]?.voices[0];
|
|
1693
|
+
if (target) {
|
|
1694
|
+
let at = 0;
|
|
1695
|
+
while (at < target.events.length && target.events[at].type === "context"
|
|
1696
|
+
&& (target.events[at].key || target.events[at].time)
|
|
1697
|
+
&& !target.events[at].clef)
|
|
1698
|
+
at++;
|
|
1699
|
+
target.events.splice(at, 0, { type: "context", partial });
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1564
1705
|
lilyletMeasures.push(measure);
|
|
1565
1706
|
}
|
|
1566
1707
|
// Add tempo to first measure's first voice if present
|
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.
|
|
@@ -163,7 +269,11 @@ const renderRepeatSpan = (infos, lo, hi) => {
|
|
|
163
269
|
return rangeCode(lo, hi);
|
|
164
270
|
}
|
|
165
271
|
const endInfo = repeatEnds[0];
|
|
166
|
-
|
|
272
|
+
// The repeat-start for THIS end is the latest repeat-start at or before it; a
|
|
273
|
+
// repeat-start sitting AFTER the end (e.g. the second half of a ":|:"/"::" that
|
|
274
|
+
// opens the next section) belongs to a following span, not this one. Falling
|
|
275
|
+
// back to `lo` models the implicit "repeat from the start of the span".
|
|
276
|
+
const startIdx = span.filter(i => i.repeatStart && i.index <= endInfo.index).map(i => i.index).sort((a, b) => b - a)[0] ?? lo;
|
|
167
277
|
const times = endInfo.repeatTimes ?? 2;
|
|
168
278
|
const endingStarts = span.filter(i => i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
|
|
169
279
|
if (endingStarts.length === 0) {
|
|
@@ -216,62 +326,39 @@ const renderRepeatSpan = (infos, lo, hi) => {
|
|
|
216
326
|
// Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
|
|
217
327
|
// halves of a da-capo ABA.
|
|
218
328
|
const renderRepeatSections = (infos, lo, hi) => {
|
|
219
|
-
const
|
|
220
|
-
const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
221
|
-
const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
|
|
222
|
-
if (repeatEnds.length <= 1)
|
|
223
|
-
return renderRepeatSpan(infos, lo, hi);
|
|
224
|
-
const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
|
|
329
|
+
const sections = classifyRepeatSections(infos, lo, hi);
|
|
225
330
|
const parts = [];
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// endings sit AFTER the repeat-end but BEFORE the next section starts).
|
|
231
|
-
const nextStart = repeatStarts.find(s => s > e);
|
|
232
|
-
const sectionEndingStarts = infos
|
|
233
|
-
.filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
|
|
234
|
-
.map(i => i.index)
|
|
235
|
-
.sort((a, b) => a - b);
|
|
236
|
-
let sectionHi;
|
|
237
|
-
if (sectionEndingStarts.length) {
|
|
238
|
-
const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
|
|
239
|
-
// the last ending's stop closes the section; else the ending start itself
|
|
240
|
-
const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
|
|
241
|
-
sectionHi = stop ?? lastStart;
|
|
331
|
+
for (const section of sections) {
|
|
332
|
+
if (section.spurious) {
|
|
333
|
+
parts.push(rangeCode(section.lo, section.hi));
|
|
334
|
+
continue;
|
|
242
335
|
}
|
|
243
|
-
|
|
244
|
-
sectionHi = e;
|
|
245
|
-
const code = renderRepeatSpan(infos, sectionLo, sectionHi);
|
|
336
|
+
const code = renderRepeatSpan(infos, section.lo, section.hi);
|
|
246
337
|
if (code === null)
|
|
247
338
|
return null;
|
|
248
339
|
parts.push(code);
|
|
249
|
-
prevHi = sectionHi;
|
|
250
340
|
}
|
|
251
|
-
// trailing non-repeated tail
|
|
252
|
-
if (prevHi < hi)
|
|
253
|
-
parts.push(rangeCode(prevHi + 1, hi));
|
|
254
341
|
return parts.join(", ");
|
|
255
342
|
};
|
|
256
|
-
const tryStructured = (infos, total) => {
|
|
343
|
+
const tryStructured = (infos, total, performed) => {
|
|
257
344
|
const dacapo = infos.find(i => i.dacapo);
|
|
258
345
|
const dalsegno = infos.some(i => i.dalsegno);
|
|
259
346
|
const tocoda = infos.some(i => i.tocoda);
|
|
260
|
-
// D.C.
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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);
|
|
271
358
|
}
|
|
272
|
-
if (dalsegno || tocoda
|
|
359
|
+
if (dalsegno || tocoda)
|
|
273
360
|
return null;
|
|
274
|
-
// 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
|
|
275
362
|
// real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
|
|
276
363
|
// render each independently and join with commas.
|
|
277
364
|
return renderRepeatSections(infos, 1, total);
|
|
@@ -285,7 +372,7 @@ const tryStructured = (infos, total) => {
|
|
|
285
372
|
// via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
|
|
286
373
|
// bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
|
|
287
374
|
// re-expand check still validates the result against the simulated order.
|
|
288
|
-
const buildDaCapoABA = (infos, dcIdx) => {
|
|
375
|
+
const buildDaCapoABA = (infos, dcIdx, performed) => {
|
|
289
376
|
const fineIdx = infos.find(i => i.fine)?.index;
|
|
290
377
|
if (fineIdx === undefined) {
|
|
291
378
|
// No explicit Fine: fall back to the legacy single-volta ABA shape, where
|
|
@@ -293,19 +380,54 @@ const buildDaCapoABA = (infos, dcIdx) => {
|
|
|
293
380
|
return buildDaCapoABALegacy(infos, dcIdx);
|
|
294
381
|
}
|
|
295
382
|
if (fineIdx >= dcIdx)
|
|
296
|
-
return null; // Fine must precede the D.C. tail
|
|
383
|
+
return null; // Fine must precede the D.C./D.S. tail
|
|
297
384
|
const restLo = fineIdx + 1;
|
|
298
|
-
if (restLo > dcIdx)
|
|
299
|
-
return null; // no B section
|
|
300
385
|
const mainCode = renderRepeatSections(infos, 1, fineIdx);
|
|
301
|
-
const restCode = renderRepeatSections(infos, restLo, dcIdx);
|
|
386
|
+
const restCode = restLo > dcIdx ? "" : renderRepeatSections(infos, restLo, dcIdx);
|
|
302
387
|
if (mainCode === null || restCode === null)
|
|
303
|
-
return
|
|
304
|
-
// main
|
|
305
|
-
//
|
|
306
|
-
|
|
388
|
+
return buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
|
|
389
|
+
// `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
|
|
390
|
+
// in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
|
|
391
|
+
// which parseItem stops at the first number). A lone number, or a single
|
|
392
|
+
// already-structured token ("2*[..]" / "2*[..]{..}" / "[..]"), parses as one item
|
|
393
|
+
// and needs no extra bracket. `rest` is parsed as a sequence, so it only needs
|
|
394
|
+
// bracketing to group a comma sequence — a range there is fine.
|
|
395
|
+
const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
|
|
396
|
+
const main = isSingleToken ? mainCode : `[${mainCode}]`;
|
|
307
397
|
const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
|
|
308
|
-
|
|
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;
|
|
309
431
|
};
|
|
310
432
|
// Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
|
|
311
433
|
// volta/repeat structure before the D.C., rest = the post-volta tail. Kept for
|
|
@@ -339,10 +461,9 @@ const buildDaCapoABALegacy = (infos, dcIdx) => {
|
|
|
339
461
|
const voltaCode = `${times}*[${rangeCode(startIdx, bodyEnd)}]{${altCode}}`;
|
|
340
462
|
const pre = startIdx > 1 ? rangeCode(1, startIdx - 1) + ", " : "";
|
|
341
463
|
const main = pre ? `[${pre}${voltaCode}]` : voltaCode;
|
|
342
|
-
// rest = the tail from after the last ending to the D.C. measure
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
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);
|
|
346
467
|
return `<${main}, ${rest}>`;
|
|
347
468
|
};
|
|
348
469
|
/**
|
|
@@ -361,7 +482,7 @@ export const buildMeasureLayout = (infos, totalMeasures) => {
|
|
|
361
482
|
// No actual unfolding happened (e.g. a lone start-repeat with no end): skip.
|
|
362
483
|
if (performed.length === totalMeasures && performed.every((v, k) => v === k + 1))
|
|
363
484
|
return undefined;
|
|
364
|
-
const structured = tryStructured(infos, totalMeasures);
|
|
485
|
+
const structured = tryStructured(infos, totalMeasures, performed);
|
|
365
486
|
if (structured) {
|
|
366
487
|
// Trust the structured form only if it reproduces the simulated order.
|
|
367
488
|
try {
|
|
@@ -371,6 +492,9 @@ export const buildMeasureLayout = (infos, totalMeasures) => {
|
|
|
371
492
|
}
|
|
372
493
|
catch { /* fall through to flat */ }
|
|
373
494
|
}
|
|
495
|
+
const compacted = compactFlatToCode(performed);
|
|
496
|
+
if (compacted !== flatToCode(performed) && layoutExpandsTo(compacted, performed))
|
|
497
|
+
return compacted;
|
|
374
498
|
return flatToCode(performed);
|
|
375
499
|
};
|
|
376
500
|
// Recognize navigation semantics from a <words> string (case-insensitive).
|
|
@@ -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[];
|