@k-l-lambda/lilylet 0.1.59 → 0.1.62
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 +273 -169
- package/lib/lilylet/lilypondDecoder.js +194 -33
- package/lib/lilylet/lilypondEncoder.js +117 -7
- package/lib/lilylet/musicXmlEncoder.js +1 -1
- package/lib/lilylet/parser.d.ts +12 -1
- package/lib/lilylet/parser.js +11 -1
- package/lib/lilylet/serializer.js +80 -10
- package/lib/lilylet/types.d.ts +8 -2
- package/package.json +12 -7
- package/source/abc/TODO.md +97 -0
- package/source/lilylet/grammar.jison.js +273 -169
- package/source/lilylet/lilylet.jison +114 -15
- package/source/lilylet/lilypondDecoder.ts +174 -36
- package/source/lilylet/lilypondEncoder.ts +114 -9
- package/source/lilylet/meiEncoder.ts +2 -1
- package/source/lilylet/musicXmlDecoder.ts +2 -2
- package/source/lilylet/musicXmlEncoder.ts +1 -1
- package/source/lilylet/parser.ts +20 -0
- package/source/lilylet/serializer.ts +73 -11
- package/source/lilylet/types.ts +10 -2
|
@@ -301,6 +301,10 @@ const serializeContextChange = (event) => {
|
|
|
301
301
|
if (event.time) {
|
|
302
302
|
parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
|
|
303
303
|
}
|
|
304
|
+
// Partial (pickup measure duration check)
|
|
305
|
+
if (event.partial) {
|
|
306
|
+
parts.push('\\partial ' + event.partial.division + '.'.repeat(event.partial.dots || 0));
|
|
307
|
+
}
|
|
304
308
|
// Ottava
|
|
305
309
|
if (event.ottava !== undefined) {
|
|
306
310
|
if (event.ottava === 0) {
|
|
@@ -343,8 +347,11 @@ const serializeTempo = (tempo) => {
|
|
|
343
347
|
const serializeTupletEvent = (event, env) => {
|
|
344
348
|
const parts = [];
|
|
345
349
|
let currentEnv = env;
|
|
346
|
-
// \times numerator/denominator
|
|
347
|
-
|
|
350
|
+
// \tuplet denominator/numerator { ... } for tuplet type, \times numerator/denominator for times type
|
|
351
|
+
const keyword = event.type === 'times'
|
|
352
|
+
? '\\times ' + event.ratio.numerator + '/' + event.ratio.denominator
|
|
353
|
+
: '\\tuplet ' + event.ratio.denominator + '/' + event.ratio.numerator;
|
|
354
|
+
parts.push(keyword + ' {');
|
|
348
355
|
let prevDuration;
|
|
349
356
|
for (const e of event.events) {
|
|
350
357
|
if (e.type === 'note') {
|
|
@@ -359,6 +366,20 @@ const serializeTupletEvent = (event, env) => {
|
|
|
359
366
|
currentEnv = newEnv;
|
|
360
367
|
prevDuration = e.duration;
|
|
361
368
|
}
|
|
369
|
+
else if (e.type === 'context') {
|
|
370
|
+
const ctx = e;
|
|
371
|
+
if (ctx.staff != null) {
|
|
372
|
+
parts.push(' \\staff "' + ctx.staff + '"');
|
|
373
|
+
}
|
|
374
|
+
else if (ctx.stemDirection != null) {
|
|
375
|
+
if (ctx.stemDirection === StemDirection.up)
|
|
376
|
+
parts.push(' \\stemUp');
|
|
377
|
+
else if (ctx.stemDirection === StemDirection.down)
|
|
378
|
+
parts.push(' \\stemDown');
|
|
379
|
+
else if (ctx.stemDirection === StemDirection.auto)
|
|
380
|
+
parts.push(' \\stemNeutral');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
362
383
|
}
|
|
363
384
|
parts.push(' }');
|
|
364
385
|
return { str: parts.join(''), newEnv: currentEnv };
|
|
@@ -428,6 +449,7 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
428
449
|
case 'context':
|
|
429
450
|
return { str: serializeContextChange(event), newEnv: env };
|
|
430
451
|
case 'tuplet':
|
|
452
|
+
case 'times':
|
|
431
453
|
return serializeTupletEvent(event, env);
|
|
432
454
|
case 'tremolo':
|
|
433
455
|
return serializeTremoloEvent(event, env);
|
|
@@ -463,10 +485,45 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
463
485
|
let prevDuration;
|
|
464
486
|
// Each voice starts fresh from middle C (step=0, octave=0)
|
|
465
487
|
let pitchEnv = { step: 0, octave: 0 };
|
|
488
|
+
// Scan leading context-staff events (before the first musical event or clef/ottava)
|
|
489
|
+
// to compute the effective initial staff. Multiple consecutive staff switches
|
|
490
|
+
// before any music collapse to the last one (earlier ones are no-ops).
|
|
491
|
+
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
492
|
+
// context{staff} events before this index are skipped in the main loop.
|
|
493
|
+
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
494
|
+
let effectiveInitialStaff = voice.staff;
|
|
495
|
+
let leadStaffScanEnd = 0;
|
|
496
|
+
for (let i = 0; i < voice.events.length; i++) {
|
|
497
|
+
const e = voice.events[i];
|
|
498
|
+
if (e.type === 'pitchReset') {
|
|
499
|
+
leadStaffScanEnd = i + 1;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (e.type === 'context') {
|
|
503
|
+
const ctx = e;
|
|
504
|
+
if (ctx.staff != null) {
|
|
505
|
+
effectiveInitialStaff = ctx.staff;
|
|
506
|
+
if (!ctx.clef && !ctx.ottava) {
|
|
507
|
+
// Pure staff-only event — absorb
|
|
508
|
+
leadStaffScanEnd = i + 1;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
// Compound (staff + clef/ottava) — update effectiveInitialStaff but stop scan
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
if (ctx.clef || ctx.ottava)
|
|
515
|
+
break; // musical context — stop scan
|
|
516
|
+
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (MUSICAL_TYPES.has(e.type))
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
466
522
|
// Output staff command if voice staff differs from current parser staff,
|
|
467
|
-
// or always output if it's a grand staff score for clarity
|
|
468
|
-
|
|
469
|
-
|
|
523
|
+
// or always output if it's a grand staff score for clarity.
|
|
524
|
+
// Use effectiveInitialStaff so carry-over replaces the default emission.
|
|
525
|
+
if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
|
|
526
|
+
parts.push('\\staff "' + effectiveInitialStaff + '"');
|
|
470
527
|
}
|
|
471
528
|
// Output key/time signatures after \staff (for first voice only)
|
|
472
529
|
if (measureContext && isFirstVoice) {
|
|
@@ -498,9 +555,14 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
498
555
|
}
|
|
499
556
|
// Skip redundant clef context events if this staff's clef is already established
|
|
500
557
|
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
501
|
-
let activeStaff =
|
|
558
|
+
let activeStaff = effectiveInitialStaff;
|
|
502
559
|
let activeStemDir;
|
|
503
|
-
for (
|
|
560
|
+
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
561
|
+
const event = voice.events[eventIdx];
|
|
562
|
+
// Skip leading context-staff events already absorbed into effectiveInitialStaff
|
|
563
|
+
if (eventIdx < leadStaffScanEnd && event.type === 'context' && event.staff != null) {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
504
566
|
if (event.type === 'context') {
|
|
505
567
|
const ctx = event;
|
|
506
568
|
// Cross-staff context: update activeStaff and emit \staff directive
|
|
@@ -516,8 +578,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
516
578
|
}
|
|
517
579
|
continue;
|
|
518
580
|
}
|
|
519
|
-
if (ctx.staff)
|
|
520
|
-
continue; // same staff, no-op
|
|
581
|
+
if (ctx.staff && !ctx.clef && !ctx.ottava)
|
|
582
|
+
continue; // same staff, pure no-op
|
|
583
|
+
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
521
584
|
// Skip clef-only context events if clef already established for this staff
|
|
522
585
|
if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
|
|
523
586
|
continue;
|
|
@@ -566,6 +629,13 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
566
629
|
else if (event.type === 'rest') {
|
|
567
630
|
prevDuration = event.duration;
|
|
568
631
|
}
|
|
632
|
+
else if (event.type === 'tuplet' || event.type === 'times') {
|
|
633
|
+
// After a tuplet/times block the LilyPond parser's "current duration" is the
|
|
634
|
+
// last note duration inside the tuplet, not the duration before the tuplet.
|
|
635
|
+
// Reset prevDuration so the first note after the block always emits its
|
|
636
|
+
// duration explicitly, avoiding wrong inheritance from inside the tuplet.
|
|
637
|
+
prevDuration = undefined;
|
|
638
|
+
}
|
|
569
639
|
else if (event.type === 'context' && event.clef && emittedClefs) {
|
|
570
640
|
const ctx = event;
|
|
571
641
|
emittedClefs[ctx.staff || activeStaff] = ctx.clef;
|
|
@@ -626,7 +696,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
626
696
|
}
|
|
627
697
|
staff = newStaff;
|
|
628
698
|
}
|
|
629
|
-
parts.push(partStrs.join('
|
|
699
|
+
parts.push(partStrs.join(' \\\\\\\n'));
|
|
630
700
|
}
|
|
631
701
|
return { str: parts.join(' '), newStaff: staff };
|
|
632
702
|
};
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -181,6 +181,7 @@ export interface ContextChange {
|
|
|
181
181
|
type: 'context';
|
|
182
182
|
key?: KeySignature;
|
|
183
183
|
time?: Fraction;
|
|
184
|
+
partial?: Duration;
|
|
184
185
|
clef?: Clef;
|
|
185
186
|
ottava?: number;
|
|
186
187
|
stemDirection?: StemDirection;
|
|
@@ -197,7 +198,12 @@ export interface TremoloEvent {
|
|
|
197
198
|
export interface TupletEvent {
|
|
198
199
|
type: 'tuplet';
|
|
199
200
|
ratio: Fraction;
|
|
200
|
-
events: (NoteEvent | RestEvent)[];
|
|
201
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
202
|
+
}
|
|
203
|
+
export interface TimesEvent {
|
|
204
|
+
type: 'times';
|
|
205
|
+
ratio: Fraction;
|
|
206
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
201
207
|
}
|
|
202
208
|
export interface PitchResetEvent {
|
|
203
209
|
type: 'pitchReset';
|
|
@@ -215,7 +221,7 @@ export interface MarkupEvent {
|
|
|
215
221
|
content: string;
|
|
216
222
|
placement?: Placement;
|
|
217
223
|
}
|
|
218
|
-
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
224
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
219
225
|
export interface Voice {
|
|
220
226
|
staff: number;
|
|
221
227
|
events: Event[];
|
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.62",
|
|
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",
|
|
@@ -27,12 +27,16 @@
|
|
|
27
27
|
"build": "tsc -p tsconfig.build.json && node tools/convertGrammarToESM.cjs && node tools/fixEsmExtensions.cjs",
|
|
28
28
|
"build:grammar": "npx tsx ./tools/buildJisonParser.ts && node tools/convertGrammarToESM.cjs",
|
|
29
29
|
"prepublishOnly": "npm run build:grammar && npm run build",
|
|
30
|
-
"test": "
|
|
31
|
-
"test:mei": "
|
|
32
|
-
"test:unit": "
|
|
33
|
-
"test:
|
|
34
|
-
"test:
|
|
35
|
-
"
|
|
30
|
+
"test": "tsx ./tests/parser.ts",
|
|
31
|
+
"test:mei": "tsx ./tests/mei.ts",
|
|
32
|
+
"test:unit": "tsx ./tests/unit/encodePitch.test.ts",
|
|
33
|
+
"test:partial": "tsx ./tests/unit/partialWarning.test.ts",
|
|
34
|
+
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
35
|
+
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
36
|
+
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
37
|
+
"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",
|
|
38
|
+
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
39
|
+
"ts": "tsx"
|
|
36
40
|
},
|
|
37
41
|
"repository": {
|
|
38
42
|
"type": "git",
|
|
@@ -51,6 +55,7 @@
|
|
|
51
55
|
"jison": "^0.4.18",
|
|
52
56
|
"sha1": "^1.1.1",
|
|
53
57
|
"ts-node": "^10.9.2",
|
|
58
|
+
"tsx": "^4.21.0",
|
|
54
59
|
"typescript": "^5.3.3",
|
|
55
60
|
"verovio": "^5.7.0",
|
|
56
61
|
"xmldom": "^0.6.0",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# ABC Grammar TODO
|
|
2
|
+
|
|
3
|
+
Issues found by comparing `abc.jison` against the abcjs parser implementation.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## High Priority
|
|
8
|
+
|
|
9
|
+
### 1. Church modes in key signatures
|
|
10
|
+
|
|
11
|
+
`key_mode` only distinguishes `major` / `minor`. The `NAME` fallback does a naive
|
|
12
|
+
`startsWith("ma")` check, which mis-classifies all church modes:
|
|
13
|
+
|
|
14
|
+
- Dorian (`Dor`, `dorian`)
|
|
15
|
+
- Phrygian (`Phr`, `phrygian`)
|
|
16
|
+
- Lydian (`Lyd`, `lydian`)
|
|
17
|
+
- Mixolydian (`Mix`, `mixolydian`)
|
|
18
|
+
- Aeolian (`Aeo`, `aeolian`)
|
|
19
|
+
- Locrian (`Loc`, `locrian`)
|
|
20
|
+
- Scottish bagpipe: `HP`, `Hp`
|
|
21
|
+
|
|
22
|
+
These should be recognized as distinct modes rather than silently falling back to
|
|
23
|
+
major or minor. The `abcDecoder.ts` key-mapping logic will also need updating to
|
|
24
|
+
handle these modes.
|
|
25
|
+
|
|
26
|
+
### 2. Bare-number Q: tempo (`Q:120`)
|
|
27
|
+
|
|
28
|
+
`numeric_tempo` only matches `frac '=' number` (e.g. `Q:1/4=120`). Two common
|
|
29
|
+
forms are not parsed:
|
|
30
|
+
|
|
31
|
+
- `Q:120` — plain BPM number (unit inferred from the current meter/L: value)
|
|
32
|
+
- `Q:"Allegro"` — text-only tempo marking
|
|
33
|
+
- `Q:"Allegro" 1/4=120` — combined text + numeric form
|
|
34
|
+
|
|
35
|
+
`Q:120` is the most frequently seen form in real-world ABC files.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Medium Priority
|
|
40
|
+
|
|
41
|
+
### 3. Missing rest type: `y` (spacer)
|
|
42
|
+
|
|
43
|
+
The `rest_phonet` rule covers `z`, `Z`, `x` but not `y` (an invisible spacer rest
|
|
44
|
+
used for spacing/layout). Files containing `y` currently cause a parse error.
|
|
45
|
+
|
|
46
|
+
### 4. Volta ending bracket (`endEnding`)
|
|
47
|
+
|
|
48
|
+
The `bar` rule appends the ending number directly to the bar token string
|
|
49
|
+
(`'|' + N → "|1"`). There is no representation of the *closing* bracket of a
|
|
50
|
+
first-ending, so `[1 ... [2` style repeat structures cannot be round-tripped.
|
|
51
|
+
abcjs tracks `startEnding` / `endEnding` flags on bar elements; consider a
|
|
52
|
+
similar approach.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Low Priority
|
|
57
|
+
|
|
58
|
+
### 5. `~` maps to `mordent` instead of `irishroll`
|
|
59
|
+
|
|
60
|
+
`abc.jison` line 507: `'~' → articulation("mordent")`.
|
|
61
|
+
The standard ABC specification defines `~` as an *Irish roll* ornament, not a
|
|
62
|
+
mordent. abcjs uses the name `irishroll`. The mordent is correctly represented by
|
|
63
|
+
`M`. This is a semantic mismatch that may affect downstream rendering/export.
|
|
64
|
+
|
|
65
|
+
### 6. Microtonal accidentals (`^/`, `_/`)
|
|
66
|
+
|
|
67
|
+
The `accidentals` rule does not cover quarter-tone accidentals:
|
|
68
|
+
|
|
69
|
+
- `^/` → quarter sharp
|
|
70
|
+
- `_/` → quarter flat
|
|
71
|
+
|
|
72
|
+
These are recognised by abcjs. Files using microtonal notation will silently drop
|
|
73
|
+
the accidental.
|
|
74
|
+
|
|
75
|
+
### 7. Short trill decoration `t`
|
|
76
|
+
|
|
77
|
+
The single-letter `t` (half/short trill, `trillh` in abcjs) is not handled. The
|
|
78
|
+
current `P`/`PP` lexer patterns only match uppercase ornament letters
|
|
79
|
+
(`HJLMOPRSTuv`), so lowercase `t` falls through as an unknown token.
|
|
80
|
+
|
|
81
|
+
### 8. Overlay voices (`&`)
|
|
82
|
+
|
|
83
|
+
The `&` operator (alternative voice within a single bar, same staff) is not
|
|
84
|
+
supported. abcjs resolves overlays into separate voices via `resolveOverlays()`.
|
|
85
|
+
This is a moderately common pattern in two-voice piano or lute transcriptions.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Out of Scope (noted for awareness)
|
|
90
|
+
|
|
91
|
+
- **Lyrics** (`w:` / `W:` fields): not handled at the ABC grammar level; notes
|
|
92
|
+
have no `lyric` property. Would require grammar additions and decoder support.
|
|
93
|
+
- **Extended clef types**: only `treble`, `bass`, `tenor` are recognised. abcjs
|
|
94
|
+
also handles `alto`, `baritone`, `mezzo`, `soprano`, `tab`, `perc`, etc.
|
|
95
|
+
- **Decoration name aliases**: abcjs normalises `tr`→`trill`, `emphasis`→`accent`,
|
|
96
|
+
`marcato`→`umarcato`, `<`/`>`→`accent`. The lilylet grammar passes `NAME`
|
|
97
|
+
through as-is; the decoder would need to handle the aliases explicitly.
|