@k-l-lambda/lilylet 0.1.75 → 0.1.78

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.
Files changed (34) hide show
  1. package/lib/abc/grammar.jison.js +85 -79
  2. package/lib/lilylet/abcDecoder.js +167 -0
  3. package/lib/lilylet/grammar.jison.js +143 -125
  4. package/lib/lilylet/highlight.js +3 -1
  5. package/lib/lilylet/lilypondEncoder.js +10 -7
  6. package/lib/lilylet/measureLayout.d.ts +11 -0
  7. package/lib/lilylet/measureLayout.js +97 -0
  8. package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
  9. package/lib/lilylet/measureLayoutFromXml.js +3 -1
  10. package/lib/lilylet/meiEncoder.js +146 -70
  11. package/lib/lilylet/musicXmlDecoder.js +119 -24
  12. package/lib/lilylet/musicXmlEncoder.js +85 -18
  13. package/lib/lilylet/musicXmlTypes.d.ts +3 -1
  14. package/lib/lilylet/musicXmlUtils.js +1 -1
  15. package/lib/lilylet/serializer.js +21 -2
  16. package/lib/lilylet/types.d.ts +6 -2
  17. package/lib/lilylet/types.js +1 -0
  18. package/package.json +3 -1
  19. package/source/abc/abc.jison +8 -4
  20. package/source/abc/grammar.jison.js +85 -79
  21. package/source/lilylet/abcDecoder.ts +189 -0
  22. package/source/lilylet/grammar.jison.js +143 -125
  23. package/source/lilylet/highlight.ts +3 -1
  24. package/source/lilylet/lilylet.jison +33 -17
  25. package/source/lilylet/lilypondEncoder.ts +11 -7
  26. package/source/lilylet/measureLayout.ts +92 -0
  27. package/source/lilylet/measureLayoutFromXml.ts +3 -1
  28. package/source/lilylet/meiEncoder.ts +164 -66
  29. package/source/lilylet/musicXmlDecoder.ts +117 -23
  30. package/source/lilylet/musicXmlEncoder.ts +89 -16
  31. package/source/lilylet/musicXmlTypes.ts +5 -1
  32. package/source/lilylet/musicXmlUtils.ts +1 -1
  33. package/source/lilylet/serializer.ts +21 -2
  34. package/source/lilylet/types.ts +10 -1
@@ -129,6 +129,14 @@
129
129
  const markupEvent = (content, placement) => ({ type: 'markup', content, placement: placement || undefined });
130
130
  const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
131
131
  const markupMark = (content) => ({ markType: 'markup', content });
132
+ const glissando = () => ({ markType: 'glissando' });
133
+
134
+ // Strip the surrounding quotes from a STRING token and unescape the control
135
+ // sequences the serializer emits (escapeString): \n \r \t \" \\ . A single
136
+ // left-to-right pass over each \x pair avoids the sequential-replace bug where
137
+ // an escaped backslash (\\) followed by n would be misread as a newline.
138
+ const UNESCAPE = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
139
+ const unq = (s) => s.slice(1, -1).replace(/\\(.)/g, (m, c) => (c in UNESCAPE ? UNESCAPE[c] : m));
132
140
 
133
141
  // Build an { instruments: { <key>: { name, shortName? } } } fragment from a
134
142
  // `[instrument-<key>` header token. <key> is a staff-layout group key — a single
@@ -254,7 +262,7 @@
254
262
  \[auto\-beam return 'HEADER_AUTOBEAM'
255
263
  \] return ']'
256
264
 
257
- \"[^"]*\" return 'STRING'
265
+ \"([^\"\\]|\\.)*\" return 'STRING'
258
266
 
259
267
  "\\clef" return 'CMD_CLEF'
260
268
  "\\key" return 'CMD_KEY'
@@ -303,6 +311,8 @@
303
311
  "\\fermata" return 'ORN_FERMATA'
304
312
  "\\shortfermata" return 'ORN_SHORTFERMATA'
305
313
  "\\arpeggio" return 'ORN_ARPEGGIO'
314
+ "\\breathe" return 'ORN_BREATHE'
315
+ "\\glissando" return 'CMD_GLISSANDO'
306
316
 
307
317
  "\\ppp" return 'DYN_PPP'
308
318
  "\\pp" return 'DYN_PP'
@@ -391,16 +401,16 @@ headers
391
401
  ;
392
402
 
393
403
  header
394
- : HEADER_TITLE STRING ']' -> ({ title: $2.slice(1, -1) })
395
- | HEADER_SUBTITLE STRING ']' -> ({ subtitle: $2.slice(1, -1) })
396
- | HEADER_COMPOSER STRING ']' -> ({ composer: $2.slice(1, -1) })
397
- | HEADER_ARRANGER STRING ']' -> ({ arranger: $2.slice(1, -1) })
398
- | HEADER_LYRICIST STRING ']' -> ({ lyricist: $2.slice(1, -1) })
399
- | HEADER_OPUS STRING ']' -> ({ opus: $2.slice(1, -1) })
400
- | HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2.slice(1, -1) })
401
- | HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, $2.slice(1, -1))
402
- | HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2.slice(1, -1), $3.slice(1, -1))
403
- | HEADER_GENRE STRING ']' -> ({ genre: $2.slice(1, -1) })
404
+ : HEADER_TITLE STRING ']' -> ({ title: unq($2) })
405
+ | HEADER_SUBTITLE STRING ']' -> ({ subtitle: unq($2) })
406
+ | HEADER_COMPOSER STRING ']' -> ({ composer: unq($2) })
407
+ | HEADER_ARRANGER STRING ']' -> ({ arranger: unq($2) })
408
+ | HEADER_LYRICIST STRING ']' -> ({ lyricist: unq($2) })
409
+ | HEADER_OPUS STRING ']' -> ({ opus: unq($2) })
410
+ | HEADER_INSTRUMENT STRING ']' -> ({ instrument: unq($2) })
411
+ | HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, unq($2))
412
+ | HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, unq($2), unq($3))
413
+ | HEADER_GENRE STRING ']' -> ({ genre: unq($2) })
404
414
  | HEADER_STAVES STRING ']' -> ({ staves: $2.slice(1, -1) })
405
415
  | HEADER_MEASURES STRING ']' -> ({ measureLayout: $2.slice(1, -1) })
406
416
  | HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
@@ -486,9 +496,9 @@ harmony_event
486
496
  ;
487
497
 
488
498
  markup_event
489
- : CMD_MARKUP STRING -> markupEvent($2.slice(1, -1))
490
- | '^' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'above')
491
- | '_' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'below')
499
+ : CMD_MARKUP STRING -> markupEvent(unq($2))
500
+ | '^' CMD_MARKUP STRING -> markupEvent(unq($3), 'above')
501
+ | '_' CMD_MARKUP STRING -> markupEvent(unq($3), 'below')
492
502
  ;
493
503
 
494
504
  dynamic_event
@@ -606,8 +616,8 @@ partial_cmd
606
616
  ;
607
617
 
608
618
  tempo_cmd
609
- : CMD_TEMPO STRING duration '=' NUMBER -> ({ text: $2.slice(1, -1), beat: $3, bpm: Number($5) })
610
- | CMD_TEMPO STRING -> ({ text: $2.slice(1, -1) })
619
+ : CMD_TEMPO STRING duration '=' NUMBER -> ({ text: unq($2), beat: $3, bpm: Number($5) })
620
+ | CMD_TEMPO STRING -> ({ text: unq($2) })
611
621
  | CMD_TEMPO duration '=' NUMBER -> ({ beat: $2, bpm: Number($4) })
612
622
  ;
613
623
 
@@ -670,6 +680,7 @@ post_event
670
680
  | fingering_number
671
681
  | navigation_mark
672
682
  | markup_mark
683
+ | glissando_mark
673
684
  ;
674
685
 
675
686
  rest_mark
@@ -704,6 +715,7 @@ ornament_mark
704
715
  | ORN_FERMATA -> ornament('fermata')
705
716
  | ORN_SHORTFERMATA -> ornament('shortFermata')
706
717
  | ORN_ARPEGGIO -> ornament('arpeggio')
718
+ | ORN_BREATHE -> ornament('breath')
707
719
  ;
708
720
 
709
721
  dynamic_mark
@@ -769,5 +781,9 @@ navigation_mark
769
781
  ;
770
782
 
771
783
  markup_mark
772
- : CMD_MARKUP STRING -> markupMark($2.slice(1, -1))
784
+ : CMD_MARKUP STRING -> markupMark(unq($2))
785
+ ;
786
+
787
+ glissando_mark
788
+ : CMD_GLISSANDO -> glissando()
773
789
  ;
@@ -751,26 +751,30 @@ const encodeVoice = (
751
751
  const encodeMetadata = (metadata: Metadata): string => {
752
752
  const entries: string[] = [];
753
753
 
754
+ // LilyPond strings escape backslash and double-quote with a backslash. An
755
+ // unescaped " (or \) inside a header value otherwise breaks the \header block.
756
+ const ly = (s: string): string => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
757
+
754
758
  if (metadata.title) {
755
- entries.push(` title = "${metadata.title}"`);
759
+ entries.push(` title = "${ly(metadata.title)}"`);
756
760
  }
757
761
  if (metadata.subtitle) {
758
- entries.push(` subtitle = "${metadata.subtitle}"`);
762
+ entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
759
763
  }
760
764
  if (metadata.composer) {
761
- entries.push(` composer = "${metadata.composer}"`);
765
+ entries.push(` composer = "${ly(metadata.composer)}"`);
762
766
  }
763
767
  if (metadata.arranger) {
764
- entries.push(` arranger = "${metadata.arranger}"`);
768
+ entries.push(` arranger = "${ly(metadata.arranger)}"`);
765
769
  }
766
770
  if (metadata.lyricist) {
767
- entries.push(` poet = "${metadata.lyricist}"`);
771
+ entries.push(` poet = "${ly(metadata.lyricist)}"`);
768
772
  }
769
773
  if (metadata.opus) {
770
- entries.push(` opus = "${metadata.opus}"`);
774
+ entries.push(` opus = "${ly(metadata.opus)}"`);
771
775
  }
772
776
  if (metadata.instrument) {
773
- entries.push(` instrument = "${metadata.instrument}"`);
777
+ entries.push(` instrument = "${ly(metadata.instrument)}"`);
774
778
  }
775
779
 
776
780
  entries.push(' tagline = ##f');
@@ -412,6 +412,98 @@ export const collectVoltaSpans = (layout: MeasureLayout): VoltaEndingSpan[] => {
412
412
  };
413
413
 
414
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
+
415
507
  // ── Serializer ───────────────────────────────────────────────────────────
416
508
  // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
417
509
  // "A..B" ranges (lotus's seqToCode).
@@ -309,7 +309,9 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
309
309
  // "D.C. al Fine" → dacapo (the Fine stop is marked separately where "Fine"
310
310
  // appears); "D.S. al Coda"/"D.S." → dalsegno; "To Coda" → tocoda; bare "Fine"
311
311
  // → fine; bare "Coda"/"Segno" → mark the landing point.
312
- const applyNavText = (info: MeasureRepeatInfo, raw: string): void => {
312
+ // Exported so the ABC decoder can feed its !D.C.!/!D.S.!/!fine! decoration text
313
+ // through the SAME recognizer, keeping the ABC and MusicXML nav semantics identical.
314
+ export const applyNavText = (info: MeasureRepeatInfo, raw: string): void => {
313
315
  const t = raw.trim().toLowerCase().replace(/\s+/g, " ");
314
316
  if (!t) return;
315
317
  const isDC = /\bd\.?\s*c\.?\b|\bda\s*capo\b/.test(t);