@k-l-lambda/lilylet 0.1.68 → 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.
@@ -37,7 +37,9 @@ import {
37
37
  MarkupEvent,
38
38
  DynamicEvent,
39
39
  Placement,
40
+ InstrumentName,
40
41
  } from "./types";
42
+ import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
41
43
 
42
44
 
43
45
  // ============ Constants ============
@@ -395,6 +397,9 @@ const convertClef = (clefStr: string): Clef | undefined => {
395
397
  // Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
396
398
  // ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
397
399
  // LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
400
+ // (Only octave amounts 8/15 are handled here; the Lilylet "_N"/"^N" clef suffix
401
+ // itself accepts arbitrary diatonic intervals — see meiEncoder.resolveClef — and a
402
+ // voice's transpose= property is folded into the same suffix via transposeClefSuffix.)
398
403
  const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
399
404
  const base = shift ? shift[1] : clefStr;
400
405
  let resolved: string | undefined;
@@ -408,6 +413,29 @@ const convertClef = (clefStr: string): Clef | undefined => {
408
413
  return resolved as Clef;
409
414
  };
410
415
 
416
+ /**
417
+ * Fold an ABC voice `transpose=N` property (a written→sounding shift in
418
+ * SEMITONES) into the Lilylet clef-suffix form `_M` / `^M`, where M is a
419
+ * diatonic interval number. ABC carries only semitones, so the diatonic
420
+ * interval is approximated by the nearest scale-step count
421
+ * (steps = round(semi * 7/12), interval number = |steps| + 1); `_` lowers,
422
+ * `^` raises. The Lilylet→MEI encoder later expands the suffix back into
423
+ * trans.diat / trans.semi (see meiEncoder.resolveClef). This is exact when the
424
+ * semitone count is a major/perfect interval (e.g. -2, -9, +2, ±12) and a
425
+ * nearest-interval approximation otherwise.
426
+ */
427
+ const transposeClefSuffix = (clef: Clef | undefined, semitones: number): Clef | undefined => {
428
+ if (!clef || !semitones) return clef;
429
+ // A clef that already carries an octave suffix (treble_8 etc.) is left as-is;
430
+ // stacking another shift on top is not meaningful for ABC sources.
431
+ if (/[_^]\d+$/.test(clef as string)) return clef;
432
+ const steps = Math.round((semitones * 7) / 12);
433
+ if (steps === 0) return clef;
434
+ const num = Math.abs(steps) + 1;
435
+ const suffix = (steps < 0 ? "_" : "^") + num;
436
+ return (clef + suffix) as Clef;
437
+ };
438
+
411
439
  /**
412
440
  * Convert ABC barline to Lilylet barline style
413
441
  */
@@ -516,6 +544,161 @@ const parseScoreLayout = (
516
544
  };
517
545
 
518
546
 
547
+ /**
548
+ * Translate an ABC %%score layout into the equivalent lilylet staves expression.
549
+ *
550
+ * The two models differ in their leaf unit: ABC %%score is VOICE-leaf (an arc `( … )`
551
+ * collapses several voices onto one staff), whereas lilylet staves is STAFF-leaf. So
552
+ * each ABC staff becomes exactly one lilylet staff id, named (per spec) by the FIRST
553
+ * voice token inside its arc.
554
+ *
555
+ * Bracket mapping (ABC → lilylet):
556
+ * ( … ) arc → one staff leaf (id = first voice) e.g. (1 3) → "1"
557
+ * bare leaf → one staff leaf (id = that voice) e.g. 7 → "7"
558
+ * { … } curly → brace (grand staff) → { … }
559
+ * [ … ] square → bracket (orchestral) → < … >
560
+ *
561
+ * Conjunction between two siblings: a trailing `|` in %%score means barlines run through
562
+ * the two staves, mapped to the solid conjunction `-`; otherwise the blank `,` is used.
563
+ */
564
+ const abcLayoutToStaves = (layout: ABC.StaffGroup[]): string | null => {
565
+ // First voice token under a node (the staff's lilylet id).
566
+ const firstVoice = (node: ABC.StaffGroup | string): string | null => {
567
+ if (typeof node === "string") return node;
568
+ for (const item of node.items || []) {
569
+ const v = firstVoice(item);
570
+ if (v !== null) return v;
571
+ }
572
+ return null;
573
+ };
574
+
575
+ // A node is a single staff (an arc, or a bare leaf) iff it has no curly/square nesting.
576
+ const isStaffLeaf = (node: ABC.StaffGroup | string): boolean => {
577
+ if (typeof node === "string") return true;
578
+ if (node.bound === "curly" || node.bound === "square") return false;
579
+ // arc or unbounded: a staff only if every descendant is too (no nested groups)
580
+ return (node.items || []).every(isStaffLeaf);
581
+ };
582
+
583
+ const emit = (node: ABC.StaffGroup | string): string => {
584
+ if (isStaffLeaf(node)) return firstVoice(node) || "";
585
+
586
+ const group = node as ABC.StaffGroup;
587
+ const open = group.bound === "curly" ? "{" : "<"; // square → bracket <>, curly → brace {}
588
+ const close = group.bound === "curly" ? "}" : ">";
589
+
590
+ const items = group.items || [];
591
+ let inner = "";
592
+ items.forEach((item, i) => {
593
+ inner += emit(item);
594
+ if (i < items.length - 1) {
595
+ const conj = (item as ABC.StaffGroup).barThruAfter ? "-" : ",";
596
+ inner += conj;
597
+ }
598
+ });
599
+ return `${open}${inner}${close}`;
600
+ };
601
+
602
+ const tops = layout.map((top, i) => {
603
+ let s = emit(top);
604
+ // A bare top-level staff leaf (e.g. the `9` in `[ … ] 9 [ … ]`) still occupies a slot;
605
+ // emit() already yields its id with no wrapper, which is the desired output.
606
+ return { s, barThru: !!top.barThruAfter, isLast: i === layout.length - 1 };
607
+ });
608
+
609
+ let out = "";
610
+ tops.forEach((t, i) => {
611
+ out += t.s;
612
+ if (i < tops.length - 1) out += t.barThru ? " - " : " ";
613
+ });
614
+
615
+ out = out.trim();
616
+ return out.length > 0 ? out : null;
617
+ };
618
+
619
+
620
+ /**
621
+ * Translate ABC voice instrument names (V:n nm="…" snm="…") into a lilylet `instruments`
622
+ * map keyed by staff-layout group key.
623
+ *
624
+ * ABC names sit on individual voices; lilylet staves are staff-leaf (an arc's first voice
625
+ * is the staff id). So we first collect a per-staff name from the staff-id voice. Then,
626
+ * per the user's rule: if a GROUP carries an instrument only on its first staff and the
627
+ * rest of the group is unnamed, the name belongs to the whole group — hoist it to the
628
+ * group key and drop it from the leaf. This matches engraving convention (one bracketed
629
+ * section, e.g. "Violini", named once for the group rather than on its top staff).
630
+ *
631
+ * `voiceInstr` maps an ABC voice number to its {name, shortName}. The layout's staff ids
632
+ * are arc-first voice numbers, so a staff id "5" looks up voice 5's name.
633
+ */
634
+ const abcInstrumentsToLilylet = (
635
+ stavesCode: string,
636
+ voiceInstr: Map<number, InstrumentName>,
637
+ ): { [key: string]: InstrumentName } | null => {
638
+ const layout = parseStaffLayout(stavesCode);
639
+ const result: { [key: string]: InstrumentName } = {};
640
+
641
+ // Per-staff instrument, keyed by staff id (the arc-first voice number as a string).
642
+ const staffInstr = new Map<string, InstrumentName>();
643
+ for (const id of layout.staffIds) {
644
+ const v = parseInt(id, 10);
645
+ if (!isNaN(v) && voiceInstr.has(v)) staffInstr.set(id, voiceInstr.get(v)!);
646
+ }
647
+
648
+ // First staff id under a group (in layout order).
649
+ const firstStaffId = (group: StaffGroup): string | undefined => {
650
+ if (group.staff) return group.staff;
651
+ for (const sub of group.subs || []) {
652
+ const id = firstStaffId(sub);
653
+ if (id !== undefined) return id;
654
+ }
655
+ return undefined;
656
+ };
657
+
658
+ // All staff ids under a group except the first.
659
+ const restStaffIds = (group: StaffGroup): string[] => {
660
+ const all: string[] = [];
661
+ const collect = (g: StaffGroup) => {
662
+ if (g.staff) all.push(g.staff);
663
+ (g.subs || []).forEach(collect);
664
+ };
665
+ collect(group);
666
+ return all.slice(1);
667
+ };
668
+
669
+ // Walk groups top-down; hoist a first-staff-only name onto the group, else keep leaves.
670
+ const walk = (group: StaffGroup) => {
671
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
672
+ if (isLeaf) {
673
+ // A plain leaf keeps its own name (assigned in the flush pass below).
674
+ return;
675
+ }
676
+
677
+ const first = firstStaffId(group);
678
+ const firstInstr = first !== undefined ? staffInstr.get(first) : undefined;
679
+ const rest = restStaffIds(group);
680
+ const restNamed = rest.some(id => staffInstr.has(id));
681
+
682
+ // Group with a real grouping symbol, named only on its first staff → hoist.
683
+ const hasSymbol = group.type !== StaffGroupType.Default;
684
+ if (hasSymbol && firstInstr && !restNamed && group.key !== undefined) {
685
+ result[group.key] = firstInstr;
686
+ staffInstr.delete(first!); // consumed by the group
687
+ return; // children below the hoisted name carry nothing
688
+ }
689
+
690
+ for (const sub of group.subs || []) walk(sub);
691
+ };
692
+
693
+ walk(layout.group);
694
+
695
+ // Flush any per-staff names that weren't hoisted onto a group.
696
+ for (const [id, instr] of staffInstr) result[id] = instr;
697
+
698
+ return Object.keys(result).length > 0 ? result : null;
699
+ };
700
+
701
+
519
702
  // ============ Marks/Decorations Conversion ============
520
703
 
521
704
  const convertArticulationMark = (artName: string): Mark | undefined => {
@@ -768,7 +951,7 @@ const processBarPatch = (
768
951
 
769
952
  // Grace notes
770
953
  if ((term as any).grace) {
771
- const graceEvents = convertGraceEvents(term as any, unitLength);
954
+ const graceEvents = convertGraceEvents(term as any, unitLength, pitchCtx);
772
955
  events.push(...graceEvents);
773
956
  i++;
774
957
  continue;
@@ -955,7 +1138,8 @@ const convertEventTerm = (
955
1138
  */
956
1139
  const convertGraceEvents = (
957
1140
  graceTerm: any,
958
- unitLength: { numerator: number; denominator: number }
1141
+ unitLength: { numerator: number; denominator: number },
1142
+ pitchCtx?: PitchContext
959
1143
  ): NoteEvent[] => {
960
1144
  const events: NoteEvent[] = [];
961
1145
  if (!graceTerm.events) return events;
@@ -968,7 +1152,7 @@ const convertGraceEvents = (
968
1152
 
969
1153
  const pitches = chord.pitches.filter((p: ABC.Pitch) =>
970
1154
  p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x"
971
- ).map(convertPitch);
1155
+ ).map((p: ABC.Pitch) => convertPitch(p, pitchCtx));
972
1156
 
973
1157
  if (pitches.length === 0) continue;
974
1158
 
@@ -1102,7 +1286,17 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1102
1286
  properties: voiceValue?.properties,
1103
1287
  });
1104
1288
  if (clefStr) {
1105
- const clef = convertClef(clefStr);
1289
+ let clef = convertClef(clefStr);
1290
+ // Fold a voice transpose= (semitones) into the clef suffix so it
1291
+ // survives to MEI as trans.diat/trans.semi (transposing instruments
1292
+ // like "Clarinet transpose=-2", "Horn transpose=-9").
1293
+ const transposeRaw = (voiceValue as any)?.properties?.transpose;
1294
+ const transposeSemi = typeof transposeRaw === "number"
1295
+ ? transposeRaw
1296
+ : (transposeRaw != null ? Number(transposeRaw) : NaN);
1297
+ if (clef && Number.isFinite(transposeSemi) && transposeSemi !== 0) {
1298
+ clef = transposeClefSuffix(clef, transposeSemi);
1299
+ }
1106
1300
  if (clef) voiceClefs.set(voiceId, clef);
1107
1301
  }
1108
1302
  }
@@ -1114,6 +1308,36 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1114
1308
  // Parse score layout
1115
1309
  const scoreLayout = parseScoreLayout(headers);
1116
1310
 
1311
+ // Translate the ABC %%score layout into a lilylet staves expression (staff-leaf model).
1312
+ const layoutHeader = headers.find((h: any) => h.staffLayout);
1313
+ if (layoutHeader) {
1314
+ const staves = abcLayoutToStaves((layoutHeader as any).staffLayout);
1315
+ if (staves) metadata.staves = staves;
1316
+
1317
+ // Translate per-voice nm/snm into a lilylet instruments map. Collect names by ABC
1318
+ // voice number (the staff id is the arc-first voice), then let abcInstrumentsToLilylet
1319
+ // apply the first-staff-only → whole-group hoisting rule.
1320
+ if (metadata.staves) {
1321
+ const voiceInstr = new Map<number, InstrumentName>();
1322
+ for (const [vid, config] of voiceConfigs) {
1323
+ const props = config.properties;
1324
+ if (!props) continue;
1325
+ const name = props.nm ?? props.name;
1326
+ if (typeof name !== "string" || !name.length) continue;
1327
+ const v = typeof vid === "number" ? vid : parseInt(String(vid), 10);
1328
+ if (isNaN(v)) continue;
1329
+ const short = props.snm ?? props.sname;
1330
+ voiceInstr.set(v, typeof short === "string" && short.length
1331
+ ? { name, shortName: short }
1332
+ : { name });
1333
+ }
1334
+ if (voiceInstr.size > 0) {
1335
+ const instruments = abcInstrumentsToLilylet(metadata.staves, voiceInstr);
1336
+ if (instruments) metadata.instruments = instruments;
1337
+ }
1338
+ }
1339
+ }
1340
+
1117
1341
  // Group measures by voice
1118
1342
  // ABC measures contain BarPatches, each with a voice control V:n
1119
1343
  const measures = body.measures;