@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.
@@ -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
- parts.push('\\times ' + event.ratio.numerator + '/' + event.ratio.denominator + ' {');
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
- if (isGrandStaff || voice.staff !== currentStaff) {
469
- parts.push('\\staff "' + voice.staff + '"');
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 = voice.staff;
558
+ let activeStaff = effectiveInitialStaff;
502
559
  let activeStemDir;
503
- for (const event of voice.events) {
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 for staff field; clef handled below
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(' \\\\\\\\\n'));
699
+ parts.push(partStrs.join(' \\\\\\\n'));
630
700
  }
631
701
  return { str: parts.join(' '), newStaff: staff };
632
702
  };
@@ -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.59",
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": "npx tsx ./tests/parser.ts",
31
- "test:mei": "npx tsx ./tests/mei.ts",
32
- "test:unit": "npx tsx ./tests/unit/encodePitch.test.ts",
33
- "test:decoder": "npx tsx ./tests/lilypondDecoder.ts",
34
- "test:abc": "npx tsx ./tests/abc-decoder.ts",
35
- "ts": "npx tsx"
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.