@k-l-lambda/lilylet 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * Measure-layout (performance / repeat order) parser + expander, ported from
3
+ * lotus (`jison/measureLayout.jison` + `inc/measureLayout.ts`).
4
+ *
5
+ * A measure-layout string encodes the order in which notated measures are
6
+ * PLAYED — repeats, voltas, da-capo / ABA — independently of the notated
7
+ * sequence. It compiles to MEI `<expansion plist="#m1 #m2 #m1 …">`, which is
8
+ * what verovio's `expand` option unfolds for MIDI / time-map output.
9
+ *
10
+ * Two modes:
11
+ * - index-wise (`i:`, the default): leaves are 1-based measure INDICES.
12
+ * e.g. "2*[1..8]{9,10}, 11..16"
13
+ * - segment-wise (`s:`): leaves are segment LENGTHS; measures are
14
+ * auto-numbered left-to-right from 1. e.g. "s: 4 <2 6> 2"
15
+ *
16
+ * Constructs (both modes): `N` single, `A..B` inclusive range (index-wise
17
+ * only), `[ … ]` block, `N*[ … ]{ alt1, alt2 }` volta with alternate endings,
18
+ * `< main, rest >` ABA / da-capo (play `main rest main`).
19
+ *
20
+ * Expansion semantics mirror lotus `LayoutType`: `Full` unfolds every repeat
21
+ * (volta body repeated once per pass with each alternate ending; ABA → A B A) —
22
+ * this is the form used for MIDI unfolding.
23
+ */
24
+
25
+ export enum LayoutType {
26
+ Ordinary = "ordinary", // volta body once + all alternates appended; ABA → A B
27
+ Full = "full", // every repeat unfolded; ABA → A B A
28
+ Conservative = "conservative",
29
+ Once = "once", // volta body once + last alternate; ABA → B A'
30
+ }
31
+
32
+ export interface SingleMLayout { kind: "single"; measure: number; }
33
+ export interface BlockMLayout { kind: "block"; seq: MeasureLayout[]; }
34
+ export interface VoltaMLayout { kind: "volta"; times: number; body: MeasureLayout[]; alternates: MeasureLayout[][] | null; }
35
+ export interface ABAMLayout { kind: "aba"; main: MeasureLayout; rest: MeasureLayout[]; }
36
+
37
+ // Transient leaf used only while parsing segment-wise mode; replaced by
38
+ // SingleMLayout(s) during the index-assignment pass before expansion.
39
+ interface SegmentMLayout { kind: "segment"; length: number; }
40
+
41
+ export type MeasureLayout = SingleMLayout | BlockMLayout | VoltaMLayout | ABAMLayout;
42
+
43
+ type ParsedLayout = MeasureLayout | SegmentMLayout;
44
+
45
+ const single = (measure: number): SingleMLayout => ({ kind: "single", measure });
46
+ const block = (seq: ParsedLayout[]): BlockMLayout => ({ kind: "block", seq: seq as MeasureLayout[] });
47
+ const volta = (times: number, body: ParsedLayout[], alternates: ParsedLayout[][] | null): VoltaMLayout =>
48
+ ({ kind: "volta", times, body: body as MeasureLayout[], alternates: alternates as MeasureLayout[][] | null });
49
+ const aba = (main: ParsedLayout, rest: ParsedLayout[]): ABAMLayout =>
50
+ ({ kind: "aba", main: main as MeasureLayout, rest: rest as MeasureLayout[] });
51
+ const segment = (length: number): SegmentMLayout => ({ kind: "segment", length });
52
+
53
+
54
+ // ── Tokenizer ────────────────────────────────────────────────────────────
55
+ // Tokens: UNSIGNED (positive int), ".." (range), the specials * , [ ] < > { },
56
+ // and the mode prefixes "i:" / "s:". Whitespace is insignificant.
57
+
58
+ type Token =
59
+ | { t: "num"; v: number }
60
+ | { t: "range" } // ".."
61
+ | { t: "mode"; v: "i" | "s" }
62
+ | { t: "sym"; v: "*" | "," | "[" | "]" | "<" | ">" | "{" | "}" }
63
+ | { t: "eof" };
64
+
65
+ const SYMS = new Set(["*", ",", "[", "]", "<", ">", "{", "}"]);
66
+
67
+ const tokenize = (code: string): Token[] => {
68
+ const tokens: Token[] = [];
69
+ let i = 0;
70
+ const n = code.length;
71
+ while (i < n) {
72
+ const c = code[i];
73
+ if (/\s/.test(c)) { i++; continue; }
74
+ if (c === "." && code[i + 1] === ".") { tokens.push({ t: "range" }); i += 2; continue; }
75
+ if (c >= "1" && c <= "9") {
76
+ let j = i + 1;
77
+ while (j < n && code[j] >= "0" && code[j] <= "9") j++;
78
+ tokens.push({ t: "num", v: Number(code.slice(i, j)) });
79
+ i = j;
80
+ continue;
81
+ }
82
+ if (SYMS.has(c)) { tokens.push({ t: "sym", v: c as any }); i++; continue; }
83
+ // mode prefix: letters followed by ':'
84
+ if (/[a-z]/i.test(c)) {
85
+ let j = i;
86
+ while (j < n && /[a-z]/i.test(code[j])) j++;
87
+ const word = code.slice(i, j);
88
+ if (code[j] === ":") {
89
+ if (word === "i" || word === "s") { tokens.push({ t: "mode", v: word }); i = j + 1; continue; }
90
+ throw new Error(`unknown measure-layout mode prefix: "${word}:"`);
91
+ }
92
+ throw new Error(`unexpected token in measure-layout: "${word}"`);
93
+ }
94
+ throw new Error(`unexpected character in measure-layout: "${c}" at ${i}`);
95
+ }
96
+ tokens.push({ t: "eof" });
97
+ return tokens;
98
+ };
99
+
100
+
101
+ // ── Recursive-descent parser ─────────────────────────────────────────────
102
+ // Faithful to lotus's jison grammar. The two modes share the same construct
103
+ // shapes; the only differences are: segment-wise leaves are SEGMENT LENGTHS
104
+ // (not indices), there are no ranges in segment-wise, and the ABA separator is
105
+ // a comma in index-wise but plain juxtaposition in segment-wise.
106
+
107
+ class Parser {
108
+ private tokens: Token[];
109
+ private pos = 0;
110
+ private segMode: boolean;
111
+
112
+ constructor (tokens: Token[], segMode: boolean) {
113
+ this.tokens = tokens;
114
+ this.segMode = segMode;
115
+ }
116
+
117
+ private peek (): Token { return this.tokens[this.pos]; }
118
+
119
+ private isSym (v: string): boolean {
120
+ const tk = this.peek();
121
+ return tk.t === "sym" && tk.v === v;
122
+ }
123
+
124
+ private expectSym (v: string): void {
125
+ if (!this.isSym(v))
126
+ throw new Error(`measure-layout: expected "${v}", got ${JSON.stringify(this.peek())}`);
127
+ this.pos++;
128
+ }
129
+
130
+ private takeNum (): number {
131
+ const tk = this.peek();
132
+ if (tk.t !== "num")
133
+ throw new Error(`measure-layout: expected number, got ${JSON.stringify(tk)}`);
134
+ this.pos++;
135
+ return tk.v;
136
+ }
137
+
138
+ // top-level sequence → a Block (or the single bare block if it's the only item)
139
+ parseTop (): MeasureLayout {
140
+ const seq = this.parseSequence();
141
+ if (seq.length === 1 && seq[0].kind === "block")
142
+ return seq[0] as BlockMLayout;
143
+ return block(seq);
144
+ }
145
+
146
+ // A comma-separated (index-wise) or juxtaposed (segment-wise) sequence of
147
+ // items, terminated by EOF or a closing ] > } token.
148
+ private parseSequence (): ParsedLayout[] {
149
+ const seq: ParsedLayout[] = [];
150
+ for (;;) {
151
+ const tk = this.peek();
152
+ if (tk.t === "eof" || this.isSym("]") || this.isSym(">") || this.isSym("}")) break;
153
+
154
+ if (!this.segMode && tk.t === "num" && this.tokens[this.pos + 1]?.t === "range") {
155
+ // index-wise range A..B
156
+ const start = this.takeNum();
157
+ this.pos++; // consume ".."
158
+ const end = this.takeNum();
159
+ if (!(end >= start)) throw new Error(`invalid measure range: ${start}..${end}`);
160
+ for (let m = start; m <= end; m++) seq.push(single(m));
161
+ }
162
+ else {
163
+ seq.push(this.parseItem());
164
+ }
165
+
166
+ // index-wise items are comma-separated; consume an optional comma
167
+ if (!this.segMode && this.isSym(",")) this.pos++;
168
+ }
169
+ return seq;
170
+ }
171
+
172
+ private parseItem (): ParsedLayout {
173
+ // volta: N *[ … ]{ … }
174
+ if (this.peek().t === "num" && this.tokens[this.pos + 1]?.t === "sym" && (this.tokens[this.pos + 1] as any).v === "*") {
175
+ const times = this.takeNum();
176
+ this.pos++; // consume '*'
177
+ const body = this.parseBlock();
178
+ let alternates: ParsedLayout[][] | null = null;
179
+ if (this.isSym("{")) alternates = this.parseAlternates();
180
+ return volta(times, body, alternates);
181
+ }
182
+ // block
183
+ if (this.isSym("[")) return block(this.parseBlock());
184
+ // ABA < main rest >
185
+ if (this.isSym("<")) return this.parseAba();
186
+ // leaf
187
+ const v = this.takeNum();
188
+ return this.segMode ? segment(v) : single(v);
189
+ }
190
+
191
+ private parseBlock (): ParsedLayout[] {
192
+ this.expectSym("[");
193
+ const seq = this.parseSequence();
194
+ this.expectSym("]");
195
+ return seq;
196
+ }
197
+
198
+ private parseAlternates (): ParsedLayout[][] {
199
+ this.expectSym("{");
200
+ const seq = this.parseSequence();
201
+ this.expectSym("}");
202
+ // each item becomes an ending: unwrap a block into its inner seq, wrap a
203
+ // bare item into a 1-element seq (lotus's `alternates` helper)
204
+ return seq.map(item => item.kind === "block" ? (item as BlockMLayout).seq as ParsedLayout[] : [item]);
205
+ }
206
+
207
+ private parseAba (): ABAMLayout {
208
+ this.expectSym("<");
209
+ const main = this.parseItem();
210
+ if (!this.segMode) this.expectSym(","); // index-wise: comma between main and rest
211
+ const rest = this.parseSequence();
212
+ this.expectSym(">");
213
+ return aba(main, rest);
214
+ }
215
+ }
216
+
217
+
218
+ // ── Segment-wise → index assignment ──────────────────────────────────────
219
+ // In segment-wise mode the leaves are segment lengths; measures auto-number
220
+ // left-to-right from 1. This walk replaces each SegmentMLayout with the right
221
+ // run of SingleMLayout, threading a shared running index (lotus's `serialize`).
222
+
223
+ const assignIndices = (item: ParsedLayout, state: { index: number }): MeasureLayout => {
224
+ switch (item.kind) {
225
+ case "single":
226
+ return item;
227
+ case "segment": {
228
+ // a lone segment (e.g. the `main` of a segment-wise ABA) → run of singles
229
+ const seq: MeasureLayout[] = [];
230
+ for (let k = 0; k < item.length; k++) seq.push(single(state.index + k));
231
+ state.index += item.length;
232
+ return seq.length === 1 ? seq[0] : { kind: "block", seq };
233
+ }
234
+ case "block":
235
+ return { kind: "block", seq: spreadSegments(item.seq, state) };
236
+ case "volta":
237
+ return {
238
+ kind: "volta",
239
+ times: item.times,
240
+ body: spreadSegments(item.body, state),
241
+ alternates: item.alternates ? item.alternates.map(seq => spreadSegments(seq, state)) : null,
242
+ };
243
+ case "aba":
244
+ return {
245
+ kind: "aba",
246
+ main: assignIndices(item.main, state),
247
+ rest: spreadSegments(item.rest, state),
248
+ };
249
+ }
250
+ };
251
+
252
+ const spreadSegments = (seq: ParsedLayout[], state: { index: number }): MeasureLayout[] => {
253
+ const out: MeasureLayout[] = [];
254
+ for (const item of seq) {
255
+ if (item.kind === "segment") {
256
+ for (let k = 0; k < item.length; k++) out.push(single(state.index + k));
257
+ state.index += item.length;
258
+ }
259
+ else {
260
+ out.push(assignIndices(item, state));
261
+ }
262
+ }
263
+ return out;
264
+ };
265
+
266
+
267
+ /**
268
+ * Parse a measure-layout string into its AST. The optional `i:` / `s:` prefix
269
+ * selects index-wise (default) or segment-wise mode; segment-wise leaves are
270
+ * resolved to 1-based indices here, so the returned AST is mode-agnostic.
271
+ */
272
+ export const parseMeasureLayout = (code: string): MeasureLayout => {
273
+ const tokens = tokenize(code);
274
+ let segMode = false;
275
+ let start = 0;
276
+ if (tokens[0].t === "mode") {
277
+ segMode = tokens[0].v === "s";
278
+ start = 1;
279
+ }
280
+ const parser = new Parser(tokens.slice(start), segMode);
281
+ const ast = parser.parseTop();
282
+ if (segMode) {
283
+ const state = { index: 1 };
284
+ return assignIndices(ast, state);
285
+ }
286
+ return ast;
287
+ };
288
+
289
+
290
+ // ── Expander ─────────────────────────────────────────────────────────────
291
+ // Port of lotus's MeasureLayout.serialize(type). Returns the performed order
292
+ // as a flat list of 1-based measure indices.
293
+
294
+ const spreadSeq = (seq: MeasureLayout[], type: LayoutType): number[] =>
295
+ ([] as number[]).concat(...seq.map(item => expand(item, type)));
296
+
297
+ const expand = (item: MeasureLayout, type: LayoutType): number[] => {
298
+ switch (item.kind) {
299
+ case "single":
300
+ return [item.measure];
301
+
302
+ case "block":
303
+ return spreadSeq(item.seq, type);
304
+
305
+ case "volta": {
306
+ const bodySeq = spreadSeq(item.body, LayoutType.Ordinary);
307
+
308
+ if (item.alternates) {
309
+ const alternateSeqs = item.alternates.map(seq => spreadSeq(seq, LayoutType.Ordinary));
310
+ const lastAlternateSeq = alternateSeqs[alternateSeqs.length - 1];
311
+
312
+ switch (type) {
313
+ case LayoutType.Ordinary:
314
+ return ([] as number[]).concat(bodySeq, ...alternateSeqs);
315
+
316
+ case LayoutType.Conservative:
317
+ case LayoutType.Full: {
318
+ const prior = ([] as number[]).concat(
319
+ ...Array(item.times - 1).fill(null).map((_, i) => [
320
+ ...bodySeq,
321
+ ...alternateSeqs[i % (item.times - 1)],
322
+ ]),
323
+ );
324
+ return [...prior, ...bodySeq, ...lastAlternateSeq];
325
+ }
326
+
327
+ case LayoutType.Once:
328
+ return [...bodySeq, ...lastAlternateSeq];
329
+ }
330
+ }
331
+ else {
332
+ switch (type) {
333
+ case LayoutType.Ordinary:
334
+ case LayoutType.Conservative:
335
+ case LayoutType.Once:
336
+ return bodySeq;
337
+
338
+ case LayoutType.Full:
339
+ return ([] as number[]).concat(...Array(item.times).fill(null).map(() => bodySeq));
340
+ }
341
+ }
342
+ return bodySeq; // unreachable; keeps the type checker happy
343
+ }
344
+
345
+ case "aba": {
346
+ const seqA = expand(item.main, type);
347
+ const seqA_ = expand(item.main, LayoutType.Once);
348
+ const seqB = spreadSeq(item.rest, type);
349
+
350
+ switch (type) {
351
+ case LayoutType.Ordinary: // A B
352
+ return [...seqA, ...seqB];
353
+ case LayoutType.Once: // B A'
354
+ return [...seqB, ...seqA_];
355
+ case LayoutType.Conservative: // A B A'
356
+ case LayoutType.Full: // A B A'
357
+ return [...seqA, ...seqB, ...seqA_];
358
+ }
359
+ return [...seqA, ...seqB, ...seqA_];
360
+ }
361
+ }
362
+ };
363
+
364
+ /**
365
+ * Expand a measure-layout AST to the performed order as 1-based measure
366
+ * indices. `LayoutType.Full` (the default) unfolds every repeat — the form
367
+ * used to build an MEI `<expansion>` for MIDI playback.
368
+ */
369
+ export const expandMeasureLayout = (layout: MeasureLayout, type: LayoutType = LayoutType.Full): number[] =>
370
+ expand(layout, type);
371
+
372
+
373
+ // ── Volta-ending spans ─────────────────────────────────────────────────────
374
+ // Every measure index covered by an alternate of a VoltaMLayout, tagged with its
375
+ // 1-based ending number. Used by the MEI encoder to wrap the right run of
376
+ // <measure>s in an <ending n="N"> container so verovio draws the 1./2. brackets.
377
+ // (The notated/play ORDER is still driven by <expansion>; this only supplies the
378
+ // visual house brackets, which expansion alone does not produce.)
379
+ export interface VoltaEndingSpan { number: number; measures: number[]; }
380
+
381
+ export const collectVoltaSpans = (layout: MeasureLayout): VoltaEndingSpan[] => {
382
+ const spans: VoltaEndingSpan[] = [];
383
+ // Indices a node covers in notation order (each measure once, ascending).
384
+ const indicesOf = (node: MeasureLayout): number[] => {
385
+ switch (node.kind) {
386
+ case "single": return [node.measure];
387
+ case "block": return ([] as number[]).concat(...node.seq.map(indicesOf));
388
+ case "volta": return ([] as number[]).concat(...node.body.map(indicesOf),
389
+ ...(node.alternates ? node.alternates.flat().map(indicesOf).flat() : []));
390
+ case "aba": return ([] as number[]).concat(indicesOf(node.main), ...node.rest.map(indicesOf));
391
+ }
392
+ };
393
+ const walk = (node: MeasureLayout): void => {
394
+ switch (node.kind) {
395
+ case "block": node.seq.forEach(walk); break;
396
+ case "aba": walk(node.main); node.rest.forEach(walk); break;
397
+ case "volta":
398
+ node.body.forEach(walk);
399
+ if (node.alternates) {
400
+ node.alternates.forEach((alt, ai) => {
401
+ const measures = ([] as number[]).concat(...alt.map(indicesOf)).sort((a, b) => a - b);
402
+ if (measures.length) spans.push({ number: ai + 1, measures });
403
+ alt.forEach(walk);
404
+ });
405
+ }
406
+ break;
407
+ default: break;
408
+ }
409
+ };
410
+ walk(layout);
411
+ return spans;
412
+ };
413
+
414
+
415
+ // ── Segment decomposition (for MEI nested sections + section-level expansion) ──
416
+ // Verovio draws volta house brackets only from <ending> containers, and only
417
+ // plays an <expansion> correctly when the plist references SECTION/ENDING ids
418
+ // (not bare measures) over a properly nested structure. So decompose the layout
419
+ // into contiguous measure groups — a "section" for plain runs and volta bodies,
420
+ // an "ending" for each volta alternate — and a performance order over those
421
+ // segment ids (a body segment id repeats once per pass). Returns null when the
422
+ // layout has no voltas (caller uses the simpler flat measure-level expansion) or
423
+ // can't be cleanly decomposed (non-contiguous segment → fall back to flat).
424
+
425
+ export interface LayoutSegment { id: string; kind: "section" | "ending"; endingNumber?: number; measures: number[]; }
426
+ export interface SegmentDecomposition { segments: LayoutSegment[]; order: string[]; }
427
+
428
+ export const decomposeToSegments = (layout: MeasureLayout): SegmentDecomposition | null => {
429
+ const segments: LayoutSegment[] = [];
430
+ let hasEnding = false;
431
+ let counter = 0;
432
+ const newId = (k: string) => `seg-${k}-${counter++}`;
433
+
434
+ // Indices a node covers in notation order (each measure once, ascending).
435
+ const indicesOf = (node: MeasureLayout): number[] => {
436
+ switch (node.kind) {
437
+ case "single": return [node.measure];
438
+ case "block": return ([] as number[]).concat(...node.seq.map(indicesOf));
439
+ case "volta": return ([] as number[]).concat(...node.body.map(indicesOf),
440
+ ...(node.alternates ? ([] as number[]).concat(...node.alternates.map(a => ([] as number[]).concat(...a.map(indicesOf)))) : []));
441
+ case "aba": return ([] as number[]).concat(indicesOf(node.main), ...node.rest.map(indicesOf));
442
+ }
443
+ };
444
+ const contiguousAsc = (xs: number[]): boolean => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
445
+
446
+ // Accumulate a run of plain (non-volta/non-aba) measures into one section,
447
+ // flushing whenever a structural node (volta/aba) interrupts the run.
448
+ let run: number[] = [];
449
+ const flushRun = (): void => {
450
+ if (run.length === 0) return;
451
+ if (!contiguousAsc(run)) throw new Error("non-contiguous section run");
452
+ segments.push({ id: newId("sec"), kind: "section", measures: run });
453
+ run = [];
454
+ };
455
+
456
+ // Emit segments for a node in document order. Plain singles/blocks extend the
457
+ // current run; a volta flushes the run then emits body-section + ending(s); an
458
+ // aba flushes then recurses into main and rest (so the replayed main is its own
459
+ // segment run). Throws on any non-contiguous group → caller falls back to flat.
460
+ const emit = (node: MeasureLayout): void => {
461
+ switch (node.kind) {
462
+ case "single": run.push(node.measure); break;
463
+ case "block": node.seq.forEach(emit); break;
464
+ case "aba": flushRun(); emit(node.main); flushRun(); node.rest.forEach(emit); flushRun(); break;
465
+ case "volta": {
466
+ flushRun();
467
+ const body = indicesOf({ kind: "block", seq: node.body });
468
+ if (!contiguousAsc(body)) throw new Error("non-contiguous volta body");
469
+ segments.push({ id: newId("body"), kind: "section", measures: body });
470
+ if (node.alternates) {
471
+ hasEnding = true;
472
+ node.alternates.forEach((alt, ai) => {
473
+ const m = indicesOf({ kind: "block", seq: alt });
474
+ if (!contiguousAsc(m)) throw new Error("non-contiguous ending");
475
+ segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
476
+ });
477
+ }
478
+ break;
479
+ }
480
+ }
481
+ };
482
+
483
+ try { emit(layout); flushRun(); } catch { return null; }
484
+ if (!hasEnding) return null; // no voltas → flat measure-level path is fine
485
+
486
+ // Map each measure index to the segment whose `measures` contains it. A volta
487
+ // body measure belongs to its body section; alternate measures to their ending.
488
+ const segOfMeasure = new Map<number, string>();
489
+ for (const s of segments) for (const m of s.measures) if (!segOfMeasure.has(m)) segOfMeasure.set(m, s.id);
490
+
491
+ // Performance order over segment ids: walk the flat Full expansion and collapse
492
+ // consecutive measures that map to the same segment into one segment ref. This
493
+ // reproduces the exact playback order (body repeats → body id appears twice,
494
+ // ABA replay → main segment id reappears), keyed off the proven expander.
495
+ const flat = expandMeasureLayout(layout, LayoutType.Full);
496
+ const order: string[] = [];
497
+ for (const m of flat) {
498
+ const id = segOfMeasure.get(m);
499
+ if (id === undefined) return null; // a played measure isn't in any segment → bail to flat
500
+ if (order.length === 0 || order[order.length - 1] !== id) order.push(id);
501
+ else if (segments.find(s => s.id === id)!.measures[0] === m) order.push(id); // same seg restarting (repeat) → new ref
502
+ }
503
+ return { segments, order };
504
+ };
505
+
506
+
507
+ // ── Serializer ───────────────────────────────────────────────────────────
508
+ // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
509
+ // "A..B" ranges (lotus's seqToCode).
510
+
511
+ const seqToCode = (seq: MeasureLayout[], withBrackets = false): string => {
512
+ let code = "";
513
+ let inRange = false;
514
+ for (let i = 0; i < seq.length; i++) {
515
+ const prev = seq[i - 1], cur = seq[i], next = seq[i + 1];
516
+ const middle = prev?.kind === "single" && cur.kind === "single" && next?.kind === "single"
517
+ && (prev as SingleMLayout).measure + 1 === (cur as SingleMLayout).measure
518
+ && (cur as SingleMLayout).measure + 1 === (next as SingleMLayout).measure;
519
+ if (middle) {
520
+ if (!inRange) { code += ".."; inRange = true; }
521
+ }
522
+ else {
523
+ if (i > 0 && !inRange) code += ", ";
524
+ inRange = false;
525
+ code += layoutToCode(cur);
526
+ }
527
+ }
528
+ return withBrackets ? `[${code}]` : code;
529
+ };
530
+
531
+ const layoutToCode = (item: MeasureLayout): string => {
532
+ switch (item.kind) {
533
+ case "single":
534
+ return item.measure.toString();
535
+ case "block":
536
+ return seqToCode(item.seq, true);
537
+ case "volta": {
538
+ let code = `${item.times}*${seqToCode(item.body, true)}`;
539
+ if (item.alternates)
540
+ code += "{" + item.alternates.map(seq => seqToCode(seq, seq.length > 1)).join(", ") + "}";
541
+ return code;
542
+ }
543
+ case "aba":
544
+ return "<" + layoutToCode(item.main) + ", " + seqToCode(item.rest) + ">";
545
+ }
546
+ };
547
+
548
+ /** Serialize an AST back to the canonical index-wise DSL string. */
549
+ export const serializeMeasureLayout = (layout: MeasureLayout): string => {
550
+ if (layout.kind === "block") return seqToCode(layout.seq);
551
+ return layoutToCode(layout);
552
+ };