@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.
- package/lib/abc/grammar.jison.js +85 -79
- package/lib/lilylet/abcDecoder.js +167 -0
- package/lib/lilylet/grammar.jison.js +143 -125
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/lilypondEncoder.js +10 -7
- package/lib/lilylet/measureLayout.d.ts +11 -0
- package/lib/lilylet/measureLayout.js +97 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +146 -70
- package/lib/lilylet/musicXmlDecoder.js +119 -24
- package/lib/lilylet/musicXmlEncoder.js +85 -18
- package/lib/lilylet/musicXmlTypes.d.ts +3 -1
- package/lib/lilylet/musicXmlUtils.js +1 -1
- package/lib/lilylet/serializer.js +21 -2
- package/lib/lilylet/types.d.ts +6 -2
- package/lib/lilylet/types.js +1 -0
- package/package.json +3 -1
- package/source/abc/abc.jison +8 -4
- package/source/abc/grammar.jison.js +85 -79
- package/source/lilylet/abcDecoder.ts +189 -0
- package/source/lilylet/grammar.jison.js +143 -125
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +33 -17
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +92 -0
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +164 -66
- package/source/lilylet/musicXmlDecoder.ts +117 -23
- package/source/lilylet/musicXmlEncoder.ts +89 -16
- package/source/lilylet/musicXmlTypes.ts +5 -1
- package/source/lilylet/musicXmlUtils.ts +1 -1
- package/source/lilylet/serializer.ts +21 -2
- 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
|
-
\"[
|
|
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
|
|
395
|
-
| HEADER_SUBTITLE STRING ']' -> ({ subtitle: $2
|
|
396
|
-
| HEADER_COMPOSER STRING ']' -> ({ composer: $2
|
|
397
|
-
| HEADER_ARRANGER STRING ']' -> ({ arranger: $2
|
|
398
|
-
| HEADER_LYRICIST STRING ']' -> ({ lyricist: $2
|
|
399
|
-
| HEADER_OPUS STRING ']' -> ({ opus: $2
|
|
400
|
-
| HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2
|
|
401
|
-
| HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, $2
|
|
402
|
-
| HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2
|
|
403
|
-
| HEADER_GENRE STRING ']' -> ({ genre: $2
|
|
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
|
|
490
|
-
| '^' CMD_MARKUP STRING -> markupEvent($3
|
|
491
|
-
| '_' CMD_MARKUP STRING -> markupEvent($3
|
|
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
|
|
610
|
-
| CMD_TEMPO STRING -> ({ text: $2
|
|
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
|
|
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
|
-
|
|
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);
|