@k-l-lambda/lilylet 0.1.69 → 0.1.70

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/abc.d.ts CHANGED
@@ -74,6 +74,7 @@ declare namespace ABC {
74
74
  export interface StaffGroup {
75
75
  items: (StaffGroup | string)[];
76
76
  bound?: 'arc' | 'square' | 'curly';
77
+ barThruAfter?: boolean;
77
78
  }
78
79
  export interface StaffLayout {
79
80
  staffLayout: StaffGroup[];
@@ -95,7 +95,7 @@ break;
95
95
  case 4:
96
96
  this.$ = tune($$[$0-1], $$[$0]);
97
97
  break;
98
- case 11: case 12: case 13: case 14: case 16: case 20: case 67: case 101: case 104: case 105: case 106: case 107: case 136: case 137: case 163: case 164: case 222:
98
+ case 11: case 12: case 13: case 14: case 16: case 67: case 101: case 104: case 105: case 106: case 107: case 136: case 137: case 163: case 164: case 222:
99
99
  this.$ = $$[$0-1];
100
100
  break;
101
101
  case 15:
@@ -104,6 +104,9 @@ break;
104
104
  case 17:
105
105
  this.$ = ({staffLayout: $$[$0]});
106
106
  break;
107
+ case 20:
108
+ if ($$[$0-1].length) $$[$0-1][$$[$0-1].length - 1].barThruAfter = true; this.$ = $$[$0-1];
109
+ break;
107
110
  case 21:
108
111
  this.$ = staffGroup([$$[$0]]);
109
112
  break;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import parse from "../abc/parser.js";
7
7
  import { Phonet, Accidental, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
8
+ import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
8
9
  // ============ Constants ============
9
10
  // NotaGen catalog tags appear as leading single-% comments in ABC files,
10
11
  // in the order: period, composer, instrumentation. They are mapped to
@@ -335,6 +336,9 @@ const convertClef = (clefStr) => {
335
336
  // Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
336
337
  // ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
337
338
  // LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
339
+ // (Only octave amounts 8/15 are handled here; the Lilylet "_N"/"^N" clef suffix
340
+ // itself accepts arbitrary diatonic intervals — see meiEncoder.resolveClef — and a
341
+ // voice's transpose= property is folded into the same suffix via transposeClefSuffix.)
338
342
  const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
339
343
  const base = shift ? shift[1] : clefStr;
340
344
  let resolved;
@@ -355,6 +359,31 @@ const convertClef = (clefStr) => {
355
359
  resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
356
360
  return resolved;
357
361
  };
362
+ /**
363
+ * Fold an ABC voice `transpose=N` property (a written→sounding shift in
364
+ * SEMITONES) into the Lilylet clef-suffix form `_M` / `^M`, where M is a
365
+ * diatonic interval number. ABC carries only semitones, so the diatonic
366
+ * interval is approximated by the nearest scale-step count
367
+ * (steps = round(semi * 7/12), interval number = |steps| + 1); `_` lowers,
368
+ * `^` raises. The Lilylet→MEI encoder later expands the suffix back into
369
+ * trans.diat / trans.semi (see meiEncoder.resolveClef). This is exact when the
370
+ * semitone count is a major/perfect interval (e.g. -2, -9, +2, ±12) and a
371
+ * nearest-interval approximation otherwise.
372
+ */
373
+ const transposeClefSuffix = (clef, semitones) => {
374
+ if (!clef || !semitones)
375
+ return clef;
376
+ // A clef that already carries an octave suffix (treble_8 etc.) is left as-is;
377
+ // stacking another shift on top is not meaningful for ABC sources.
378
+ if (/[_^]\d+$/.test(clef))
379
+ return clef;
380
+ const steps = Math.round((semitones * 7) / 12);
381
+ if (steps === 0)
382
+ return clef;
383
+ const num = Math.abs(steps) + 1;
384
+ const suffix = (steps < 0 ? "_" : "^") + num;
385
+ return (clef + suffix);
386
+ };
358
387
  /**
359
388
  * Convert ABC barline to Lilylet barline style
360
389
  */
@@ -431,6 +460,149 @@ const parseScoreLayout = (headers) => {
431
460
  }
432
461
  return voiceMap.size > 0 ? voiceMap : null;
433
462
  };
463
+ /**
464
+ * Translate an ABC %%score layout into the equivalent lilylet staves expression.
465
+ *
466
+ * The two models differ in their leaf unit: ABC %%score is VOICE-leaf (an arc `( … )`
467
+ * collapses several voices onto one staff), whereas lilylet staves is STAFF-leaf. So
468
+ * each ABC staff becomes exactly one lilylet staff id, named (per spec) by the FIRST
469
+ * voice token inside its arc.
470
+ *
471
+ * Bracket mapping (ABC → lilylet):
472
+ * ( … ) arc → one staff leaf (id = first voice) e.g. (1 3) → "1"
473
+ * bare leaf → one staff leaf (id = that voice) e.g. 7 → "7"
474
+ * { … } curly → brace (grand staff) → { … }
475
+ * [ … ] square → bracket (orchestral) → < … >
476
+ *
477
+ * Conjunction between two siblings: a trailing `|` in %%score means barlines run through
478
+ * the two staves, mapped to the solid conjunction `-`; otherwise the blank `,` is used.
479
+ */
480
+ const abcLayoutToStaves = (layout) => {
481
+ // First voice token under a node (the staff's lilylet id).
482
+ const firstVoice = (node) => {
483
+ if (typeof node === "string")
484
+ return node;
485
+ for (const item of node.items || []) {
486
+ const v = firstVoice(item);
487
+ if (v !== null)
488
+ return v;
489
+ }
490
+ return null;
491
+ };
492
+ // A node is a single staff (an arc, or a bare leaf) iff it has no curly/square nesting.
493
+ const isStaffLeaf = (node) => {
494
+ if (typeof node === "string")
495
+ return true;
496
+ if (node.bound === "curly" || node.bound === "square")
497
+ return false;
498
+ // arc or unbounded: a staff only if every descendant is too (no nested groups)
499
+ return (node.items || []).every(isStaffLeaf);
500
+ };
501
+ const emit = (node) => {
502
+ if (isStaffLeaf(node))
503
+ return firstVoice(node) || "";
504
+ const group = node;
505
+ const open = group.bound === "curly" ? "{" : "<"; // square → bracket <>, curly → brace {}
506
+ const close = group.bound === "curly" ? "}" : ">";
507
+ const items = group.items || [];
508
+ let inner = "";
509
+ items.forEach((item, i) => {
510
+ inner += emit(item);
511
+ if (i < items.length - 1) {
512
+ const conj = item.barThruAfter ? "-" : ",";
513
+ inner += conj;
514
+ }
515
+ });
516
+ return `${open}${inner}${close}`;
517
+ };
518
+ const tops = layout.map((top, i) => {
519
+ let s = emit(top);
520
+ // A bare top-level staff leaf (e.g. the `9` in `[ … ] 9 [ … ]`) still occupies a slot;
521
+ // emit() already yields its id with no wrapper, which is the desired output.
522
+ return { s, barThru: !!top.barThruAfter, isLast: i === layout.length - 1 };
523
+ });
524
+ let out = "";
525
+ tops.forEach((t, i) => {
526
+ out += t.s;
527
+ if (i < tops.length - 1)
528
+ out += t.barThru ? " - " : " ";
529
+ });
530
+ out = out.trim();
531
+ return out.length > 0 ? out : null;
532
+ };
533
+ /**
534
+ * Translate ABC voice instrument names (V:n nm="…" snm="…") into a lilylet `instruments`
535
+ * map keyed by staff-layout group key.
536
+ *
537
+ * ABC names sit on individual voices; lilylet staves are staff-leaf (an arc's first voice
538
+ * is the staff id). So we first collect a per-staff name from the staff-id voice. Then,
539
+ * per the user's rule: if a GROUP carries an instrument only on its first staff and the
540
+ * rest of the group is unnamed, the name belongs to the whole group — hoist it to the
541
+ * group key and drop it from the leaf. This matches engraving convention (one bracketed
542
+ * section, e.g. "Violini", named once for the group rather than on its top staff).
543
+ *
544
+ * `voiceInstr` maps an ABC voice number to its {name, shortName}. The layout's staff ids
545
+ * are arc-first voice numbers, so a staff id "5" looks up voice 5's name.
546
+ */
547
+ const abcInstrumentsToLilylet = (stavesCode, voiceInstr) => {
548
+ const layout = parseStaffLayout(stavesCode);
549
+ const result = {};
550
+ // Per-staff instrument, keyed by staff id (the arc-first voice number as a string).
551
+ const staffInstr = new Map();
552
+ for (const id of layout.staffIds) {
553
+ const v = parseInt(id, 10);
554
+ if (!isNaN(v) && voiceInstr.has(v))
555
+ staffInstr.set(id, voiceInstr.get(v));
556
+ }
557
+ // First staff id under a group (in layout order).
558
+ const firstStaffId = (group) => {
559
+ if (group.staff)
560
+ return group.staff;
561
+ for (const sub of group.subs || []) {
562
+ const id = firstStaffId(sub);
563
+ if (id !== undefined)
564
+ return id;
565
+ }
566
+ return undefined;
567
+ };
568
+ // All staff ids under a group except the first.
569
+ const restStaffIds = (group) => {
570
+ const all = [];
571
+ const collect = (g) => {
572
+ if (g.staff)
573
+ all.push(g.staff);
574
+ (g.subs || []).forEach(collect);
575
+ };
576
+ collect(group);
577
+ return all.slice(1);
578
+ };
579
+ // Walk groups top-down; hoist a first-staff-only name onto the group, else keep leaves.
580
+ const walk = (group) => {
581
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
582
+ if (isLeaf) {
583
+ // A plain leaf keeps its own name (assigned in the flush pass below).
584
+ return;
585
+ }
586
+ const first = firstStaffId(group);
587
+ const firstInstr = first !== undefined ? staffInstr.get(first) : undefined;
588
+ const rest = restStaffIds(group);
589
+ const restNamed = rest.some(id => staffInstr.has(id));
590
+ // Group with a real grouping symbol, named only on its first staff → hoist.
591
+ const hasSymbol = group.type !== StaffGroupType.Default;
592
+ if (hasSymbol && firstInstr && !restNamed && group.key !== undefined) {
593
+ result[group.key] = firstInstr;
594
+ staffInstr.delete(first); // consumed by the group
595
+ return; // children below the hoisted name carry nothing
596
+ }
597
+ for (const sub of group.subs || [])
598
+ walk(sub);
599
+ };
600
+ walk(layout.group);
601
+ // Flush any per-staff names that weren't hoisted onto a group.
602
+ for (const [id, instr] of staffInstr)
603
+ result[id] = instr;
604
+ return Object.keys(result).length > 0 ? result : null;
605
+ };
434
606
  // ============ Marks/Decorations Conversion ============
435
607
  const convertArticulationMark = (artName) => {
436
608
  switch (artName) {
@@ -984,7 +1156,17 @@ const decodeTune = (tune, options = {}) => {
984
1156
  properties: voiceValue?.properties,
985
1157
  });
986
1158
  if (clefStr) {
987
- const clef = convertClef(clefStr);
1159
+ let clef = convertClef(clefStr);
1160
+ // Fold a voice transpose= (semitones) into the clef suffix so it
1161
+ // survives to MEI as trans.diat/trans.semi (transposing instruments
1162
+ // like "Clarinet transpose=-2", "Horn transpose=-9").
1163
+ const transposeRaw = voiceValue?.properties?.transpose;
1164
+ const transposeSemi = typeof transposeRaw === "number"
1165
+ ? transposeRaw
1166
+ : (transposeRaw != null ? Number(transposeRaw) : NaN);
1167
+ if (clef && Number.isFinite(transposeSemi) && transposeSemi !== 0) {
1168
+ clef = transposeClefSuffix(clef, transposeSemi);
1169
+ }
988
1170
  if (clef)
989
1171
  voiceClefs.set(voiceId, clef);
990
1172
  }
@@ -995,6 +1177,39 @@ const decodeTune = (tune, options = {}) => {
995
1177
  }
996
1178
  // Parse score layout
997
1179
  const scoreLayout = parseScoreLayout(headers);
1180
+ // Translate the ABC %%score layout into a lilylet staves expression (staff-leaf model).
1181
+ const layoutHeader = headers.find((h) => h.staffLayout);
1182
+ if (layoutHeader) {
1183
+ const staves = abcLayoutToStaves(layoutHeader.staffLayout);
1184
+ if (staves)
1185
+ metadata.staves = staves;
1186
+ // Translate per-voice nm/snm into a lilylet instruments map. Collect names by ABC
1187
+ // voice number (the staff id is the arc-first voice), then let abcInstrumentsToLilylet
1188
+ // apply the first-staff-only → whole-group hoisting rule.
1189
+ if (metadata.staves) {
1190
+ const voiceInstr = new Map();
1191
+ for (const [vid, config] of voiceConfigs) {
1192
+ const props = config.properties;
1193
+ if (!props)
1194
+ continue;
1195
+ const name = props.nm ?? props.name;
1196
+ if (typeof name !== "string" || !name.length)
1197
+ continue;
1198
+ const v = typeof vid === "number" ? vid : parseInt(String(vid), 10);
1199
+ if (isNaN(v))
1200
+ continue;
1201
+ const short = props.snm ?? props.sname;
1202
+ voiceInstr.set(v, typeof short === "string" && short.length
1203
+ ? { name, shortName: short }
1204
+ : { name });
1205
+ }
1206
+ if (voiceInstr.size > 0) {
1207
+ const instruments = abcInstrumentsToLilylet(metadata.staves, voiceInstr);
1208
+ if (instruments)
1209
+ metadata.instruments = instruments;
1210
+ }
1211
+ }
1212
+ }
998
1213
  // Group measures by voice
999
1214
  // ABC measures contain BarPatches, each with a voice control V:n
1000
1215
  const measures = body.measures;