@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.
- package/lib/lilylet/grammar.jison.js +180 -175
- package/lib/lilylet/highlight.js +1 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayout.d.ts +68 -0
- package/lib/lilylet/measureLayout.js +412 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +160 -40
- package/lib/lilylet/musicXmlDecoder.js +172 -6
- package/lib/lilylet/musicXmlTypes.d.ts +9 -0
- package/lib/lilylet/serializer.js +124 -10
- package/lib/lilylet/types.d.ts +1 -0
- package/lib/measureLayout.d.ts +1 -0
- package/lib/measureLayout.js +1 -0
- package/lib/measureLayoutFromXml.d.ts +1 -0
- package/lib/measureLayoutFromXml.js +1 -0
- package/package.json +5 -2
- package/source/lilylet/grammar.jison.js +180 -175
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/measureLayout.ts +460 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +149 -39
- package/source/lilylet/musicXmlDecoder.ts +169 -6
- package/source/lilylet/musicXmlTypes.ts +12 -0
- package/source/lilylet/serializer.ts +118 -10
- package/source/lilylet/types.ts +4 -0
|
@@ -5,6 +5,44 @@
|
|
|
5
5
|
* Uses relative pitch mode matching the parser's behavior.
|
|
6
6
|
*/
|
|
7
7
|
import { StemDirection, } from "./types.js";
|
|
8
|
+
import { parseMeasureLayout } from "./measureLayout.js";
|
|
9
|
+
// Walk a measure-layout AST, recording the FIRST measure index of each alternate
|
|
10
|
+
// ending (1st/2nd house) → its ending number, for the `% volta N` serializer
|
|
11
|
+
// comment convention. Only the alternates of a VoltaMLayout produce labels.
|
|
12
|
+
const collectVoltaLabels = (layout, out) => {
|
|
13
|
+
const firstIndexOf = (node) => {
|
|
14
|
+
switch (node.kind) {
|
|
15
|
+
case "single": return node.measure;
|
|
16
|
+
case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
|
|
17
|
+
case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
|
|
18
|
+
case "aba": return firstIndexOf(node.main);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const walk = (node) => {
|
|
22
|
+
switch (node.kind) {
|
|
23
|
+
case "block":
|
|
24
|
+
node.seq.forEach(walk);
|
|
25
|
+
break;
|
|
26
|
+
case "aba":
|
|
27
|
+
walk(node.main);
|
|
28
|
+
node.rest.forEach(walk);
|
|
29
|
+
break;
|
|
30
|
+
case "volta":
|
|
31
|
+
node.body.forEach(walk);
|
|
32
|
+
if (node.alternates) {
|
|
33
|
+
node.alternates.forEach((alt, ai) => {
|
|
34
|
+
const first = alt.length ? firstIndexOf(alt[0]) : undefined;
|
|
35
|
+
if (first !== undefined && !out.has(first))
|
|
36
|
+
out.set(first, ai + 1);
|
|
37
|
+
alt.forEach(walk);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
default: break;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(layout);
|
|
45
|
+
};
|
|
8
46
|
const PHONETS = "cdefgab";
|
|
9
47
|
/**
|
|
10
48
|
* Calculate the octave markers needed to serialize a pitch in relative mode.
|
|
@@ -369,10 +407,20 @@ const serializeTupletEvent = (event, env) => {
|
|
|
369
407
|
}
|
|
370
408
|
else if (e.type === 'context') {
|
|
371
409
|
const ctx = e;
|
|
410
|
+
// A context event inside a tuplet may carry several components at once
|
|
411
|
+
// (e.g. staff + clef). Emit each present one — previously only staff and
|
|
412
|
+
// stemDirection were handled, so a clef/ottava change inside a tuplet was
|
|
413
|
+
// silently dropped on serialization.
|
|
372
414
|
if (ctx.staff != null) {
|
|
373
415
|
parts.push(' \\staff "' + ctx.staff + '"');
|
|
374
416
|
}
|
|
375
|
-
|
|
417
|
+
if (ctx.clef != null) {
|
|
418
|
+
parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
|
|
419
|
+
}
|
|
420
|
+
if (ctx.ottava != null) {
|
|
421
|
+
parts.push(' \\ottava #' + ctx.ottava);
|
|
422
|
+
}
|
|
423
|
+
if (ctx.stemDirection != null) {
|
|
376
424
|
if (ctx.stemDirection === StemDirection.up)
|
|
377
425
|
parts.push(' \\stemUp');
|
|
378
426
|
else if (ctx.stemDirection === StemDirection.down)
|
|
@@ -511,6 +559,32 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
511
559
|
// context{staff} events before this index are skipped in the main loop.
|
|
512
560
|
let effectiveInitialStaff = voice.staff;
|
|
513
561
|
let leadStaffScanEnd = 0;
|
|
562
|
+
// Staff of the first MUSICAL event (note/rest/...), honouring an explicit
|
|
563
|
+
// note.staff. If it differs from the home staff, the first inline staff switch
|
|
564
|
+
// would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
|
|
565
|
+
// binds home to the first `\staff` before any music), relocating the voice and
|
|
566
|
+
// any leading clef. We then force an explicit home-staff anchor up front. This
|
|
567
|
+
// scan is independent of leadStaffScanEnd because a leading clef/key stops that
|
|
568
|
+
// scan before the first note is reached.
|
|
569
|
+
let firstMusicalStaff = null;
|
|
570
|
+
{
|
|
571
|
+
let scanStaff = voice.staff;
|
|
572
|
+
for (const e of voice.events) {
|
|
573
|
+
if (e.type === 'context') {
|
|
574
|
+
const ctx = e;
|
|
575
|
+
if (ctx.staff != null)
|
|
576
|
+
scanStaff = ctx.staff;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (e.type === 'pitchReset')
|
|
580
|
+
continue;
|
|
581
|
+
if (e.type === 'note')
|
|
582
|
+
firstMusicalStaff = e.staff ?? scanStaff;
|
|
583
|
+
else
|
|
584
|
+
firstMusicalStaff = scanStaff;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
514
588
|
for (let i = 0; i < voice.events.length; i++) {
|
|
515
589
|
const e = voice.events[i];
|
|
516
590
|
if (e.type === 'pitchReset') {
|
|
@@ -538,10 +612,19 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
538
612
|
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
539
613
|
break;
|
|
540
614
|
}
|
|
541
|
-
// Output staff command if voice staff
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
615
|
+
// Output staff command if the voice's initial staff is not what the PARSER will
|
|
616
|
+
// assume. The parser (lilylet.jison voice()) assigns every voice with no leading
|
|
617
|
+
// `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
|
|
618
|
+
// voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
|
|
619
|
+
// is not 1, even if the cross-measure carry (currentStaff) happens to match it
|
|
620
|
+
// (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
|
|
621
|
+
// this, the round-trip would silently relocate that voice, and its leading clef,
|
|
622
|
+
// to staff 1). Also emit when it differs from the carry (resets after grand staff)
|
|
623
|
+
// or always in a grand-staff part for clarity. Finally, force an anchor when the
|
|
624
|
+
// first note switches staff via note.staff (otherwise that inline `\staff` would
|
|
625
|
+
// be read as the leading staff and rebind the voice's home).
|
|
626
|
+
const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
|
|
627
|
+
if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
|
|
545
628
|
parts.push('\\staff "' + effectiveInitialStaff + '"');
|
|
546
629
|
}
|
|
547
630
|
// Output key/time signatures after \staff (for first voice only)
|
|
@@ -568,7 +651,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
568
651
|
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
569
652
|
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
570
653
|
// hoisted here — it is emitted inline at its position.
|
|
571
|
-
|
|
654
|
+
//
|
|
655
|
+
// The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
|
|
656
|
+
// the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
|
|
657
|
+
// with no clef of its own must only use the fallback to ESTABLISH a staff that has
|
|
658
|
+
// not been emitted yet — never to "restate" it once any sibling voice has already
|
|
659
|
+
// emitted/changed this staff's clef this measure, because the fallback would then be
|
|
660
|
+
// stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
|
|
661
|
+
// treble, then v2 with no clef must not re-emit the carried-in bass).
|
|
662
|
+
const ownClef = findVoiceClef(voice);
|
|
663
|
+
const carryClef = allStaffClefs?.[voice.staff];
|
|
664
|
+
const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
|
|
572
665
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
573
666
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
574
667
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
@@ -591,7 +684,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
591
684
|
activeStaff = ctx.staff;
|
|
592
685
|
parts.push('\\staff "' + activeStaff + '"');
|
|
593
686
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
594
|
-
const ctxClef = ctx.clef ||
|
|
687
|
+
const ctxClef = ctx.clef ||
|
|
688
|
+
(emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
|
|
595
689
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
596
690
|
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
597
691
|
if (emittedClefs)
|
|
@@ -618,7 +712,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
618
712
|
activeStaff = effectiveStaff;
|
|
619
713
|
parts.push('\\staff "' + activeStaff + '"');
|
|
620
714
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
621
|
-
const targetClef = allStaffClefs?.[activeStaff];
|
|
715
|
+
const targetClef = emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
|
|
622
716
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
623
717
|
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
624
718
|
if (emittedClefs)
|
|
@@ -776,6 +870,9 @@ const serializeMetadata = (metadata) => {
|
|
|
776
870
|
if (metadata.staves) {
|
|
777
871
|
lines.push('[staves "' + escapeString(metadata.staves) + '"]');
|
|
778
872
|
}
|
|
873
|
+
if (metadata.measureLayout) {
|
|
874
|
+
lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
|
|
875
|
+
}
|
|
779
876
|
if (metadata.instruments) {
|
|
780
877
|
for (const [key, instr] of Object.entries(metadata.instruments)) {
|
|
781
878
|
let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
|
|
@@ -856,9 +953,26 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
856
953
|
measureStrs.push(measureStr || 's1');
|
|
857
954
|
currentStaff = newStaff;
|
|
858
955
|
}
|
|
859
|
-
//
|
|
956
|
+
// Volta house-label comments (non-binding convention, like the `% n` measure
|
|
957
|
+
// index): if a measure-layout directive is present, mark the first measure of
|
|
958
|
+
// each alternate ending with a `% volta N` comment. Derived from the layout
|
|
959
|
+
// AST; purely cosmetic, never parsed back.
|
|
960
|
+
const voltaLabels = new Map(); // 1-based measure index → ending number
|
|
961
|
+
if (doc.metadata?.measureLayout) {
|
|
962
|
+
try {
|
|
963
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
964
|
+
collectVoltaLabels(layout, voltaLabels);
|
|
965
|
+
}
|
|
966
|
+
catch { /* ignore malformed layout */ }
|
|
967
|
+
}
|
|
968
|
+
// Join measures with bar, measure number comment, and double newline. A
|
|
969
|
+
// `% volta N` label (if any) is prepended on its own line before the measure.
|
|
860
970
|
const measuresOutput = measureStrs
|
|
861
|
-
.map((m, i) =>
|
|
971
|
+
.map((m, i) => {
|
|
972
|
+
const label = voltaLabels.get(i + 1);
|
|
973
|
+
const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
|
|
974
|
+
return prefix + m + ' | %' + (i + 1);
|
|
975
|
+
})
|
|
862
976
|
.join('\n\n');
|
|
863
977
|
parts.push(measuresOutput);
|
|
864
978
|
return parts.join('\n');
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayout.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayout.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayoutFromXml.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayoutFromXml.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.75",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -40,11 +40,14 @@
|
|
|
40
40
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
41
41
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
42
42
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
43
|
+
"test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
|
|
43
44
|
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
44
45
|
"train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
|
|
45
46
|
"build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
|
|
46
47
|
"build:browser-bundle": "esbuild tools/browserBundle.ts --bundle --format=iife --platform=browser --minify --outfile=dist/lilylet.bundle.js",
|
|
47
48
|
"test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
|
|
49
|
+
"test:measures": "tsx ./tests/unit/measureLayout.test.ts",
|
|
50
|
+
"test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
|
|
48
51
|
"build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
|
|
49
52
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
50
53
|
"ts": "tsx"
|
|
@@ -70,7 +73,7 @@
|
|
|
70
73
|
"ts-node": "^10.9.2",
|
|
71
74
|
"tsx": "^4.21.0",
|
|
72
75
|
"typescript": "^5.3.3",
|
|
73
|
-
"verovio": "^
|
|
76
|
+
"verovio": "^6.2.0",
|
|
74
77
|
"yargs": "^17.7.2"
|
|
75
78
|
},
|
|
76
79
|
"dependencies": {
|