@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.
@@ -21,6 +21,7 @@ export const HIGHLIGHT_RULES = [
21
21
  { re: /(?:\[instrument\b)/iy, scope: "header" },
22
22
  { re: /(?:\[genre\b)/iy, scope: "header" },
23
23
  { re: /(?:\[staves\b)/iy, scope: "header" },
24
+ { re: /(?:\[measures\b)/iy, scope: "header" },
24
25
  { re: /(?:\[auto-beam\b)/iy, scope: "header" },
25
26
  { re: /(?:\])/iy, scope: "squareBracket" },
26
27
  { re: /(?:"[^"]*")/iy, scope: "string" },
@@ -2,6 +2,7 @@ export * from "./types";
2
2
  export * from "./parser";
3
3
  export * from "./serializer";
4
4
  export * from "./staffLayout";
5
+ export * from "./measureLayout";
5
6
  import * as meiEncoder from "./meiEncoder";
6
7
  import * as musicXmlDecoder from "./musicXmlDecoder";
7
8
  import * as lilypondEncoder from "./lilypondEncoder";
@@ -2,6 +2,7 @@ export * from "./types.js";
2
2
  export * from "./parser.js";
3
3
  export * from "./serializer.js";
4
4
  export * from "./staffLayout.js";
5
+ export * from "./measureLayout.js";
5
6
  import * as meiEncoder from "./meiEncoder.js";
6
7
  import * as musicXmlDecoder from "./musicXmlDecoder.js";
7
8
  import * as lilypondEncoder from "./lilypondEncoder.js";
@@ -622,26 +622,29 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
622
622
  */
623
623
  const encodeMetadata = (metadata) => {
624
624
  const entries = [];
625
+ // LilyPond strings escape backslash and double-quote with a backslash. An
626
+ // unescaped " (or \) inside a header value otherwise breaks the \header block.
627
+ const ly = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
625
628
  if (metadata.title) {
626
- entries.push(` title = "${metadata.title}"`);
629
+ entries.push(` title = "${ly(metadata.title)}"`);
627
630
  }
628
631
  if (metadata.subtitle) {
629
- entries.push(` subtitle = "${metadata.subtitle}"`);
632
+ entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
630
633
  }
631
634
  if (metadata.composer) {
632
- entries.push(` composer = "${metadata.composer}"`);
635
+ entries.push(` composer = "${ly(metadata.composer)}"`);
633
636
  }
634
637
  if (metadata.arranger) {
635
- entries.push(` arranger = "${metadata.arranger}"`);
638
+ entries.push(` arranger = "${ly(metadata.arranger)}"`);
636
639
  }
637
640
  if (metadata.lyricist) {
638
- entries.push(` poet = "${metadata.lyricist}"`);
641
+ entries.push(` poet = "${ly(metadata.lyricist)}"`);
639
642
  }
640
643
  if (metadata.opus) {
641
- entries.push(` opus = "${metadata.opus}"`);
644
+ entries.push(` opus = "${ly(metadata.opus)}"`);
642
645
  }
643
646
  if (metadata.instrument) {
644
- entries.push(` instrument = "${metadata.instrument}"`);
647
+ entries.push(` instrument = "${ly(metadata.instrument)}"`);
645
648
  }
646
649
  entries.push(' tagline = ##f');
647
650
  return entries.join('\n');
@@ -0,0 +1,79 @@
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
+ export declare enum LayoutType {
25
+ Ordinary = "ordinary",// volta body once + all alternates appended; ABA → A B
26
+ Full = "full",// every repeat unfolded; ABA → A B A
27
+ Conservative = "conservative",
28
+ Once = "once"
29
+ }
30
+ export interface SingleMLayout {
31
+ kind: "single";
32
+ measure: number;
33
+ }
34
+ export interface BlockMLayout {
35
+ kind: "block";
36
+ seq: MeasureLayout[];
37
+ }
38
+ export interface VoltaMLayout {
39
+ kind: "volta";
40
+ times: number;
41
+ body: MeasureLayout[];
42
+ alternates: MeasureLayout[][] | null;
43
+ }
44
+ export interface ABAMLayout {
45
+ kind: "aba";
46
+ main: MeasureLayout;
47
+ rest: MeasureLayout[];
48
+ }
49
+ export type MeasureLayout = SingleMLayout | BlockMLayout | VoltaMLayout | ABAMLayout;
50
+ /**
51
+ * Parse a measure-layout string into its AST. The optional `i:` / `s:` prefix
52
+ * selects index-wise (default) or segment-wise mode; segment-wise leaves are
53
+ * resolved to 1-based indices here, so the returned AST is mode-agnostic.
54
+ */
55
+ export declare const parseMeasureLayout: (code: string) => MeasureLayout;
56
+ /**
57
+ * Expand a measure-layout AST to the performed order as 1-based measure
58
+ * indices. `LayoutType.Full` (the default) unfolds every repeat — the form
59
+ * used to build an MEI `<expansion>` for MIDI playback.
60
+ */
61
+ export declare const expandMeasureLayout: (layout: MeasureLayout, type?: LayoutType) => number[];
62
+ export interface VoltaEndingSpan {
63
+ number: number;
64
+ measures: number[];
65
+ }
66
+ export declare const collectVoltaSpans: (layout: MeasureLayout) => VoltaEndingSpan[];
67
+ export interface LayoutSegment {
68
+ id: string;
69
+ kind: "section" | "ending";
70
+ endingNumber?: number;
71
+ measures: number[];
72
+ }
73
+ export interface SegmentDecomposition {
74
+ segments: LayoutSegment[];
75
+ order: string[];
76
+ }
77
+ export declare const decomposeToSegments: (layout: MeasureLayout) => SegmentDecomposition | null;
78
+ /** Serialize an AST back to the canonical index-wise DSL string. */
79
+ export declare const serializeMeasureLayout: (layout: MeasureLayout) => string;
@@ -0,0 +1,509 @@
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
+ export var LayoutType;
25
+ (function (LayoutType) {
26
+ LayoutType["Ordinary"] = "ordinary";
27
+ LayoutType["Full"] = "full";
28
+ LayoutType["Conservative"] = "conservative";
29
+ LayoutType["Once"] = "once";
30
+ })(LayoutType || (LayoutType = {}));
31
+ const single = (measure) => ({ kind: "single", measure });
32
+ const block = (seq) => ({ kind: "block", seq: seq });
33
+ const volta = (times, body, alternates) => ({ kind: "volta", times, body: body, alternates: alternates });
34
+ const aba = (main, rest) => ({ kind: "aba", main: main, rest: rest });
35
+ const segment = (length) => ({ kind: "segment", length });
36
+ const SYMS = new Set(["*", ",", "[", "]", "<", ">", "{", "}"]);
37
+ const tokenize = (code) => {
38
+ const tokens = [];
39
+ let i = 0;
40
+ const n = code.length;
41
+ while (i < n) {
42
+ const c = code[i];
43
+ if (/\s/.test(c)) {
44
+ i++;
45
+ continue;
46
+ }
47
+ if (c === "." && code[i + 1] === ".") {
48
+ tokens.push({ t: "range" });
49
+ i += 2;
50
+ continue;
51
+ }
52
+ if (c >= "1" && c <= "9") {
53
+ let j = i + 1;
54
+ while (j < n && code[j] >= "0" && code[j] <= "9")
55
+ j++;
56
+ tokens.push({ t: "num", v: Number(code.slice(i, j)) });
57
+ i = j;
58
+ continue;
59
+ }
60
+ if (SYMS.has(c)) {
61
+ tokens.push({ t: "sym", v: c });
62
+ i++;
63
+ continue;
64
+ }
65
+ // mode prefix: letters followed by ':'
66
+ if (/[a-z]/i.test(c)) {
67
+ let j = i;
68
+ while (j < n && /[a-z]/i.test(code[j]))
69
+ j++;
70
+ const word = code.slice(i, j);
71
+ if (code[j] === ":") {
72
+ if (word === "i" || word === "s") {
73
+ tokens.push({ t: "mode", v: word });
74
+ i = j + 1;
75
+ continue;
76
+ }
77
+ throw new Error(`unknown measure-layout mode prefix: "${word}:"`);
78
+ }
79
+ throw new Error(`unexpected token in measure-layout: "${word}"`);
80
+ }
81
+ throw new Error(`unexpected character in measure-layout: "${c}" at ${i}`);
82
+ }
83
+ tokens.push({ t: "eof" });
84
+ return tokens;
85
+ };
86
+ // ── Recursive-descent parser ─────────────────────────────────────────────
87
+ // Faithful to lotus's jison grammar. The two modes share the same construct
88
+ // shapes; the only differences are: segment-wise leaves are SEGMENT LENGTHS
89
+ // (not indices), there are no ranges in segment-wise, and the ABA separator is
90
+ // a comma in index-wise but plain juxtaposition in segment-wise.
91
+ class Parser {
92
+ tokens;
93
+ pos = 0;
94
+ segMode;
95
+ constructor(tokens, segMode) {
96
+ this.tokens = tokens;
97
+ this.segMode = segMode;
98
+ }
99
+ peek() { return this.tokens[this.pos]; }
100
+ isSym(v) {
101
+ const tk = this.peek();
102
+ return tk.t === "sym" && tk.v === v;
103
+ }
104
+ expectSym(v) {
105
+ if (!this.isSym(v))
106
+ throw new Error(`measure-layout: expected "${v}", got ${JSON.stringify(this.peek())}`);
107
+ this.pos++;
108
+ }
109
+ takeNum() {
110
+ const tk = this.peek();
111
+ if (tk.t !== "num")
112
+ throw new Error(`measure-layout: expected number, got ${JSON.stringify(tk)}`);
113
+ this.pos++;
114
+ return tk.v;
115
+ }
116
+ // top-level sequence → a Block (or the single bare block if it's the only item)
117
+ parseTop() {
118
+ const seq = this.parseSequence();
119
+ if (seq.length === 1 && seq[0].kind === "block")
120
+ return seq[0];
121
+ return block(seq);
122
+ }
123
+ // A comma-separated (index-wise) or juxtaposed (segment-wise) sequence of
124
+ // items, terminated by EOF or a closing ] > } token.
125
+ parseSequence() {
126
+ const seq = [];
127
+ for (;;) {
128
+ const tk = this.peek();
129
+ if (tk.t === "eof" || this.isSym("]") || this.isSym(">") || this.isSym("}"))
130
+ break;
131
+ if (!this.segMode && tk.t === "num" && this.tokens[this.pos + 1]?.t === "range") {
132
+ // index-wise range A..B
133
+ const start = this.takeNum();
134
+ this.pos++; // consume ".."
135
+ const end = this.takeNum();
136
+ if (!(end >= start))
137
+ throw new Error(`invalid measure range: ${start}..${end}`);
138
+ for (let m = start; m <= end; m++)
139
+ seq.push(single(m));
140
+ }
141
+ else {
142
+ seq.push(this.parseItem());
143
+ }
144
+ // index-wise items are comma-separated; consume an optional comma
145
+ if (!this.segMode && this.isSym(","))
146
+ this.pos++;
147
+ }
148
+ return seq;
149
+ }
150
+ parseItem() {
151
+ // volta: N *[ … ]{ … }
152
+ if (this.peek().t === "num" && this.tokens[this.pos + 1]?.t === "sym" && this.tokens[this.pos + 1].v === "*") {
153
+ const times = this.takeNum();
154
+ this.pos++; // consume '*'
155
+ const body = this.parseBlock();
156
+ let alternates = null;
157
+ if (this.isSym("{"))
158
+ alternates = this.parseAlternates();
159
+ return volta(times, body, alternates);
160
+ }
161
+ // block
162
+ if (this.isSym("["))
163
+ return block(this.parseBlock());
164
+ // ABA < main rest >
165
+ if (this.isSym("<"))
166
+ return this.parseAba();
167
+ // leaf
168
+ const v = this.takeNum();
169
+ return this.segMode ? segment(v) : single(v);
170
+ }
171
+ parseBlock() {
172
+ this.expectSym("[");
173
+ const seq = this.parseSequence();
174
+ this.expectSym("]");
175
+ return seq;
176
+ }
177
+ parseAlternates() {
178
+ this.expectSym("{");
179
+ const seq = this.parseSequence();
180
+ this.expectSym("}");
181
+ // each item becomes an ending: unwrap a block into its inner seq, wrap a
182
+ // bare item into a 1-element seq (lotus's `alternates` helper)
183
+ return seq.map(item => item.kind === "block" ? item.seq : [item]);
184
+ }
185
+ parseAba() {
186
+ this.expectSym("<");
187
+ const main = this.parseItem();
188
+ if (!this.segMode)
189
+ this.expectSym(","); // index-wise: comma between main and rest
190
+ const rest = this.parseSequence();
191
+ this.expectSym(">");
192
+ return aba(main, rest);
193
+ }
194
+ }
195
+ // ── Segment-wise → index assignment ──────────────────────────────────────
196
+ // In segment-wise mode the leaves are segment lengths; measures auto-number
197
+ // left-to-right from 1. This walk replaces each SegmentMLayout with the right
198
+ // run of SingleMLayout, threading a shared running index (lotus's `serialize`).
199
+ const assignIndices = (item, state) => {
200
+ switch (item.kind) {
201
+ case "single":
202
+ return item;
203
+ case "segment": {
204
+ // a lone segment (e.g. the `main` of a segment-wise ABA) → run of singles
205
+ const seq = [];
206
+ for (let k = 0; k < item.length; k++)
207
+ seq.push(single(state.index + k));
208
+ state.index += item.length;
209
+ return seq.length === 1 ? seq[0] : { kind: "block", seq };
210
+ }
211
+ case "block":
212
+ return { kind: "block", seq: spreadSegments(item.seq, state) };
213
+ case "volta":
214
+ return {
215
+ kind: "volta",
216
+ times: item.times,
217
+ body: spreadSegments(item.body, state),
218
+ alternates: item.alternates ? item.alternates.map(seq => spreadSegments(seq, state)) : null,
219
+ };
220
+ case "aba":
221
+ return {
222
+ kind: "aba",
223
+ main: assignIndices(item.main, state),
224
+ rest: spreadSegments(item.rest, state),
225
+ };
226
+ }
227
+ };
228
+ const spreadSegments = (seq, state) => {
229
+ const out = [];
230
+ for (const item of seq) {
231
+ if (item.kind === "segment") {
232
+ for (let k = 0; k < item.length; k++)
233
+ out.push(single(state.index + k));
234
+ state.index += item.length;
235
+ }
236
+ else {
237
+ out.push(assignIndices(item, state));
238
+ }
239
+ }
240
+ return out;
241
+ };
242
+ /**
243
+ * Parse a measure-layout string into its AST. The optional `i:` / `s:` prefix
244
+ * selects index-wise (default) or segment-wise mode; segment-wise leaves are
245
+ * resolved to 1-based indices here, so the returned AST is mode-agnostic.
246
+ */
247
+ export const parseMeasureLayout = (code) => {
248
+ const tokens = tokenize(code);
249
+ let segMode = false;
250
+ let start = 0;
251
+ if (tokens[0].t === "mode") {
252
+ segMode = tokens[0].v === "s";
253
+ start = 1;
254
+ }
255
+ const parser = new Parser(tokens.slice(start), segMode);
256
+ const ast = parser.parseTop();
257
+ if (segMode) {
258
+ const state = { index: 1 };
259
+ return assignIndices(ast, state);
260
+ }
261
+ return ast;
262
+ };
263
+ // ── Expander ─────────────────────────────────────────────────────────────
264
+ // Port of lotus's MeasureLayout.serialize(type). Returns the performed order
265
+ // as a flat list of 1-based measure indices.
266
+ const spreadSeq = (seq, type) => [].concat(...seq.map(item => expand(item, type)));
267
+ const expand = (item, type) => {
268
+ switch (item.kind) {
269
+ case "single":
270
+ return [item.measure];
271
+ case "block":
272
+ return spreadSeq(item.seq, type);
273
+ case "volta": {
274
+ const bodySeq = spreadSeq(item.body, LayoutType.Ordinary);
275
+ if (item.alternates) {
276
+ const alternateSeqs = item.alternates.map(seq => spreadSeq(seq, LayoutType.Ordinary));
277
+ const lastAlternateSeq = alternateSeqs[alternateSeqs.length - 1];
278
+ switch (type) {
279
+ case LayoutType.Ordinary:
280
+ return [].concat(bodySeq, ...alternateSeqs);
281
+ case LayoutType.Conservative:
282
+ case LayoutType.Full: {
283
+ const prior = [].concat(...Array(item.times - 1).fill(null).map((_, i) => [
284
+ ...bodySeq,
285
+ ...alternateSeqs[i % (item.times - 1)],
286
+ ]));
287
+ return [...prior, ...bodySeq, ...lastAlternateSeq];
288
+ }
289
+ case LayoutType.Once:
290
+ return [...bodySeq, ...lastAlternateSeq];
291
+ }
292
+ }
293
+ else {
294
+ switch (type) {
295
+ case LayoutType.Ordinary:
296
+ case LayoutType.Conservative:
297
+ case LayoutType.Once:
298
+ return bodySeq;
299
+ case LayoutType.Full:
300
+ return [].concat(...Array(item.times).fill(null).map(() => bodySeq));
301
+ }
302
+ }
303
+ return bodySeq; // unreachable; keeps the type checker happy
304
+ }
305
+ case "aba": {
306
+ const seqA = expand(item.main, type);
307
+ const seqA_ = expand(item.main, LayoutType.Once);
308
+ const seqB = spreadSeq(item.rest, type);
309
+ switch (type) {
310
+ case LayoutType.Ordinary: // A B
311
+ return [...seqA, ...seqB];
312
+ case LayoutType.Once: // B A'
313
+ return [...seqB, ...seqA_];
314
+ case LayoutType.Conservative: // A B A'
315
+ case LayoutType.Full: // A B A'
316
+ return [...seqA, ...seqB, ...seqA_];
317
+ }
318
+ return [...seqA, ...seqB, ...seqA_];
319
+ }
320
+ }
321
+ };
322
+ /**
323
+ * Expand a measure-layout AST to the performed order as 1-based measure
324
+ * indices. `LayoutType.Full` (the default) unfolds every repeat — the form
325
+ * used to build an MEI `<expansion>` for MIDI playback.
326
+ */
327
+ export const expandMeasureLayout = (layout, type = LayoutType.Full) => expand(layout, type);
328
+ export const collectVoltaSpans = (layout) => {
329
+ const spans = [];
330
+ // Indices a node covers in notation order (each measure once, ascending).
331
+ const indicesOf = (node) => {
332
+ switch (node.kind) {
333
+ case "single": return [node.measure];
334
+ case "block": return [].concat(...node.seq.map(indicesOf));
335
+ case "volta": return [].concat(...node.body.map(indicesOf), ...(node.alternates ? node.alternates.flat().map(indicesOf).flat() : []));
336
+ case "aba": return [].concat(indicesOf(node.main), ...node.rest.map(indicesOf));
337
+ }
338
+ };
339
+ const walk = (node) => {
340
+ switch (node.kind) {
341
+ case "block":
342
+ node.seq.forEach(walk);
343
+ break;
344
+ case "aba":
345
+ walk(node.main);
346
+ node.rest.forEach(walk);
347
+ break;
348
+ case "volta":
349
+ node.body.forEach(walk);
350
+ if (node.alternates) {
351
+ node.alternates.forEach((alt, ai) => {
352
+ const measures = [].concat(...alt.map(indicesOf)).sort((a, b) => a - b);
353
+ if (measures.length)
354
+ spans.push({ number: ai + 1, measures });
355
+ alt.forEach(walk);
356
+ });
357
+ }
358
+ break;
359
+ default: break;
360
+ }
361
+ };
362
+ walk(layout);
363
+ return spans;
364
+ };
365
+ export const decomposeToSegments = (layout) => {
366
+ const segments = [];
367
+ let hasEnding = false;
368
+ let counter = 0;
369
+ const newId = (k) => `seg-${k}-${counter++}`;
370
+ // Indices a node covers in notation order (each measure once, ascending).
371
+ const indicesOf = (node) => {
372
+ switch (node.kind) {
373
+ case "single": return [node.measure];
374
+ case "block": return [].concat(...node.seq.map(indicesOf));
375
+ case "volta": return [].concat(...node.body.map(indicesOf), ...(node.alternates ? [].concat(...node.alternates.map(a => [].concat(...a.map(indicesOf)))) : []));
376
+ case "aba": return [].concat(indicesOf(node.main), ...node.rest.map(indicesOf));
377
+ }
378
+ };
379
+ const contiguousAsc = (xs) => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
380
+ // Accumulate a run of plain (non-volta/non-aba) measures into one section,
381
+ // flushing whenever a structural node (volta/aba) interrupts the run.
382
+ let run = [];
383
+ const flushRun = () => {
384
+ if (run.length === 0)
385
+ return;
386
+ if (!contiguousAsc(run))
387
+ throw new Error("non-contiguous section run");
388
+ segments.push({ id: newId("sec"), kind: "section", measures: run });
389
+ run = [];
390
+ };
391
+ // Emit segments for a node in document order. Plain singles/blocks extend the
392
+ // current run; a volta flushes the run then emits body-section + ending(s); an
393
+ // aba flushes then recurses into main and rest (so the replayed main is its own
394
+ // segment run). Throws on any non-contiguous group → caller falls back to flat.
395
+ const emit = (node) => {
396
+ switch (node.kind) {
397
+ case "single":
398
+ run.push(node.measure);
399
+ break;
400
+ case "block":
401
+ node.seq.forEach(emit);
402
+ break;
403
+ case "aba":
404
+ flushRun();
405
+ emit(node.main);
406
+ flushRun();
407
+ node.rest.forEach(emit);
408
+ flushRun();
409
+ break;
410
+ case "volta": {
411
+ flushRun();
412
+ const body = indicesOf({ kind: "block", seq: node.body });
413
+ if (!contiguousAsc(body))
414
+ throw new Error("non-contiguous volta body");
415
+ segments.push({ id: newId("body"), kind: "section", measures: body });
416
+ if (node.alternates) {
417
+ hasEnding = true;
418
+ node.alternates.forEach((alt, ai) => {
419
+ const m = indicesOf({ kind: "block", seq: alt });
420
+ if (!contiguousAsc(m))
421
+ throw new Error("non-contiguous ending");
422
+ segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
423
+ });
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ };
429
+ try {
430
+ emit(layout);
431
+ flushRun();
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ if (!hasEnding)
437
+ return null; // no voltas → flat measure-level path is fine
438
+ // Map each measure index to the segment whose `measures` contains it. A volta
439
+ // body measure belongs to its body section; alternate measures to their ending.
440
+ const segOfMeasure = new Map();
441
+ for (const s of segments)
442
+ for (const m of s.measures)
443
+ if (!segOfMeasure.has(m))
444
+ segOfMeasure.set(m, s.id);
445
+ // Performance order over segment ids: walk the flat Full expansion and collapse
446
+ // consecutive measures that map to the same segment into one segment ref. This
447
+ // reproduces the exact playback order (body repeats → body id appears twice,
448
+ // ABA replay → main segment id reappears), keyed off the proven expander.
449
+ const flat = expandMeasureLayout(layout, LayoutType.Full);
450
+ const order = [];
451
+ for (const m of flat) {
452
+ const id = segOfMeasure.get(m);
453
+ if (id === undefined)
454
+ return null; // a played measure isn't in any segment → bail to flat
455
+ if (order.length === 0 || order[order.length - 1] !== id)
456
+ order.push(id);
457
+ else if (segments.find(s => s.id === id).measures[0] === m)
458
+ order.push(id); // same seg restarting (repeat) → new ref
459
+ }
460
+ return { segments, order };
461
+ };
462
+ // ── Serializer ───────────────────────────────────────────────────────────
463
+ // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
464
+ // "A..B" ranges (lotus's seqToCode).
465
+ const seqToCode = (seq, withBrackets = false) => {
466
+ let code = "";
467
+ let inRange = false;
468
+ for (let i = 0; i < seq.length; i++) {
469
+ const prev = seq[i - 1], cur = seq[i], next = seq[i + 1];
470
+ const middle = prev?.kind === "single" && cur.kind === "single" && next?.kind === "single"
471
+ && prev.measure + 1 === cur.measure
472
+ && cur.measure + 1 === next.measure;
473
+ if (middle) {
474
+ if (!inRange) {
475
+ code += "..";
476
+ inRange = true;
477
+ }
478
+ }
479
+ else {
480
+ if (i > 0 && !inRange)
481
+ code += ", ";
482
+ inRange = false;
483
+ code += layoutToCode(cur);
484
+ }
485
+ }
486
+ return withBrackets ? `[${code}]` : code;
487
+ };
488
+ const layoutToCode = (item) => {
489
+ switch (item.kind) {
490
+ case "single":
491
+ return item.measure.toString();
492
+ case "block":
493
+ return seqToCode(item.seq, true);
494
+ case "volta": {
495
+ let code = `${item.times}*${seqToCode(item.body, true)}`;
496
+ if (item.alternates)
497
+ code += "{" + item.alternates.map(seq => seqToCode(seq, seq.length > 1)).join(", ") + "}";
498
+ return code;
499
+ }
500
+ case "aba":
501
+ return "<" + layoutToCode(item.main) + ", " + seqToCode(item.rest) + ">";
502
+ }
503
+ };
504
+ /** Serialize an AST back to the canonical index-wise DSL string. */
505
+ export const serializeMeasureLayout = (layout) => {
506
+ if (layout.kind === "block")
507
+ return seqToCode(layout.seq);
508
+ return layoutToCode(layout);
509
+ };