@k-l-lambda/lilylet 0.1.74 → 0.1.75

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.
@@ -67,6 +67,7 @@ export const HIGHLIGHT_RULES: HighlightRule[] = [
67
67
  { re: /(?:\[instrument\b)/iy, scope: "header" },
68
68
  { re: /(?:\[genre\b)/iy, scope: "header" },
69
69
  { re: /(?:\[staves\b)/iy, scope: "header" },
70
+ { re: /(?:\[measures\b)/iy, scope: "header" },
70
71
  { re: /(?:\[auto-beam\b)/iy, scope: "header" },
71
72
  { re: /(?:\])/iy, scope: "squareBracket" },
72
73
  { re: /(?:"[^"]*")/iy, scope: "string" },
@@ -3,6 +3,7 @@ export * from "./types";
3
3
  export * from "./parser";
4
4
  export * from "./serializer";
5
5
  export * from "./staffLayout";
6
+ export * from "./measureLayout";
6
7
 
7
8
  import * as meiEncoder from "./meiEncoder";
8
9
  import * as musicXmlDecoder from "./musicXmlDecoder";
@@ -250,6 +250,7 @@
250
250
  \[instrument return 'HEADER_INSTRUMENT'
251
251
  \[genre return 'HEADER_GENRE'
252
252
  \[staves return 'HEADER_STAVES'
253
+ \[measures return 'HEADER_MEASURES'
253
254
  \[auto\-beam return 'HEADER_AUTOBEAM'
254
255
  \] return ']'
255
256
 
@@ -401,6 +402,7 @@ header
401
402
  | HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2.slice(1, -1), $3.slice(1, -1))
402
403
  | HEADER_GENRE STRING ']' -> ({ genre: $2.slice(1, -1) })
403
404
  | HEADER_STAVES STRING ']' -> ({ staves: $2.slice(1, -1) })
405
+ | HEADER_MEASURES STRING ']' -> ({ measureLayout: $2.slice(1, -1) })
404
406
  | HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
405
407
  ;
406
408
 
@@ -0,0 +1,460 @@
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
+ // ── Serializer ───────────────────────────────────────────────────────────
416
+ // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
417
+ // "A..B" ranges (lotus's seqToCode).
418
+
419
+ const seqToCode = (seq: MeasureLayout[], withBrackets = false): string => {
420
+ let code = "";
421
+ let inRange = false;
422
+ for (let i = 0; i < seq.length; i++) {
423
+ const prev = seq[i - 1], cur = seq[i], next = seq[i + 1];
424
+ const middle = prev?.kind === "single" && cur.kind === "single" && next?.kind === "single"
425
+ && (prev as SingleMLayout).measure + 1 === (cur as SingleMLayout).measure
426
+ && (cur as SingleMLayout).measure + 1 === (next as SingleMLayout).measure;
427
+ if (middle) {
428
+ if (!inRange) { code += ".."; inRange = true; }
429
+ }
430
+ else {
431
+ if (i > 0 && !inRange) code += ", ";
432
+ inRange = false;
433
+ code += layoutToCode(cur);
434
+ }
435
+ }
436
+ return withBrackets ? `[${code}]` : code;
437
+ };
438
+
439
+ const layoutToCode = (item: MeasureLayout): string => {
440
+ switch (item.kind) {
441
+ case "single":
442
+ return item.measure.toString();
443
+ case "block":
444
+ return seqToCode(item.seq, true);
445
+ case "volta": {
446
+ let code = `${item.times}*${seqToCode(item.body, true)}`;
447
+ if (item.alternates)
448
+ code += "{" + item.alternates.map(seq => seqToCode(seq, seq.length > 1)).join(", ") + "}";
449
+ return code;
450
+ }
451
+ case "aba":
452
+ return "<" + layoutToCode(item.main) + ", " + seqToCode(item.rest) + ">";
453
+ }
454
+ };
455
+
456
+ /** Serialize an AST back to the canonical index-wise DSL string. */
457
+ export const serializeMeasureLayout = (layout: MeasureLayout): string => {
458
+ if (layout.kind === "block") return seqToCode(layout.seq);
459
+ return layoutToCode(layout);
460
+ };