@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.
@@ -36,6 +36,40 @@ import {
36
36
  Placement,
37
37
  Metadata,
38
38
  } from "./types";
39
+ import { parseMeasureLayout, MeasureLayout } from "./measureLayout";
40
+
41
+
42
+ // Walk a measure-layout AST, recording the FIRST measure index of each alternate
43
+ // ending (1st/2nd house) → its ending number, for the `% volta N` serializer
44
+ // comment convention. Only the alternates of a VoltaMLayout produce labels.
45
+ const collectVoltaLabels = (layout: MeasureLayout, out: Map<number, number>): void => {
46
+ const firstIndexOf = (node: MeasureLayout): number | undefined => {
47
+ switch (node.kind) {
48
+ case "single": return node.measure;
49
+ case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
50
+ case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
51
+ case "aba": return firstIndexOf(node.main);
52
+ }
53
+ };
54
+ const walk = (node: MeasureLayout): void => {
55
+ switch (node.kind) {
56
+ case "block": node.seq.forEach(walk); break;
57
+ case "aba": walk(node.main); node.rest.forEach(walk); break;
58
+ case "volta":
59
+ node.body.forEach(walk);
60
+ if (node.alternates) {
61
+ node.alternates.forEach((alt, ai) => {
62
+ const first = alt.length ? firstIndexOf(alt[0]) : undefined;
63
+ if (first !== undefined && !out.has(first)) out.set(first, ai + 1);
64
+ alt.forEach(walk);
65
+ });
66
+ }
67
+ break;
68
+ default: break;
69
+ }
70
+ };
71
+ walk(layout);
72
+ };
39
73
 
40
74
 
41
75
  const PHONETS = "cdefgab";
@@ -483,9 +517,20 @@ const serializeTupletEvent = (
483
517
  prevDuration = (e as RestEvent).duration;
484
518
  } else if (e.type === 'context') {
485
519
  const ctx = e as ContextChange;
520
+ // A context event inside a tuplet may carry several components at once
521
+ // (e.g. staff + clef). Emit each present one — previously only staff and
522
+ // stemDirection were handled, so a clef/ottava change inside a tuplet was
523
+ // silently dropped on serialization.
486
524
  if (ctx.staff != null) {
487
525
  parts.push(' \\staff "' + ctx.staff + '"');
488
- } else if (ctx.stemDirection != null) {
526
+ }
527
+ if (ctx.clef != null) {
528
+ parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
529
+ }
530
+ if (ctx.ottava != null) {
531
+ parts.push(' \\ottava #' + ctx.ottava);
532
+ }
533
+ if (ctx.stemDirection != null) {
489
534
  if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
490
535
  else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
491
536
  else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
@@ -654,6 +699,28 @@ const serializeVoice = (
654
699
  // context{staff} events before this index are skipped in the main loop.
655
700
  let effectiveInitialStaff = voice.staff;
656
701
  let leadStaffScanEnd = 0;
702
+ // Staff of the first MUSICAL event (note/rest/...), honouring an explicit
703
+ // note.staff. If it differs from the home staff, the first inline staff switch
704
+ // would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
705
+ // binds home to the first `\staff` before any music), relocating the voice and
706
+ // any leading clef. We then force an explicit home-staff anchor up front. This
707
+ // scan is independent of leadStaffScanEnd because a leading clef/key stops that
708
+ // scan before the first note is reached.
709
+ let firstMusicalStaff: number | null = null;
710
+ {
711
+ let scanStaff = voice.staff;
712
+ for (const e of voice.events) {
713
+ if (e.type === 'context') {
714
+ const ctx = e as ContextChange;
715
+ if (ctx.staff != null) scanStaff = ctx.staff;
716
+ continue;
717
+ }
718
+ if (e.type === 'pitchReset') continue;
719
+ if (e.type === 'note') firstMusicalStaff = (e as NoteEvent).staff ?? scanStaff;
720
+ else firstMusicalStaff = scanStaff;
721
+ break;
722
+ }
723
+ }
657
724
  for (let i = 0; i < voice.events.length; i++) {
658
725
  const e = voice.events[i];
659
726
  if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
@@ -678,10 +745,19 @@ const serializeVoice = (
678
745
  break;
679
746
  }
680
747
 
681
- // Output staff command if voice staff differs from current parser staff,
682
- // or always output if it's a grand staff score for clarity.
683
- // Use effectiveInitialStaff so carry-over replaces the default emission.
684
- if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
748
+ // Output staff command if the voice's initial staff is not what the PARSER will
749
+ // assume. The parser (lilylet.jison voice()) assigns every voice with no leading
750
+ // `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
751
+ // voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
752
+ // is not 1, even if the cross-measure carry (currentStaff) happens to match it
753
+ // (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
754
+ // this, the round-trip would silently relocate that voice, and its leading clef,
755
+ // to staff 1). Also emit when it differs from the carry (resets after grand staff)
756
+ // or always in a grand-staff part for clarity. Finally, force an anchor when the
757
+ // first note switches staff via note.staff (otherwise that inline `\staff` would
758
+ // be read as the leading staff and rebind the voice's home).
759
+ const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
760
+ if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
685
761
  parts.push('\\staff "' + effectiveInitialStaff + '"');
686
762
  }
687
763
 
@@ -710,7 +786,17 @@ const serializeVoice = (
710
786
  // Prefer this voice's leading clef (a clef before any music); fall back to the
711
787
  // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
712
788
  // hoisted here — it is emitted inline at its position.
713
- const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
789
+ //
790
+ // The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
791
+ // the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
792
+ // with no clef of its own must only use the fallback to ESTABLISH a staff that has
793
+ // not been emitted yet — never to "restate" it once any sibling voice has already
794
+ // emitted/changed this staff's clef this measure, because the fallback would then be
795
+ // stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
796
+ // treble, then v2 with no clef must not re-emit the carried-in bass).
797
+ const ownClef = findVoiceClef(voice);
798
+ const carryClef = allStaffClefs?.[voice.staff];
799
+ const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
714
800
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
715
801
  if (voiceClef && !clefAlreadyEmitted) {
716
802
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
@@ -734,7 +820,8 @@ const serializeVoice = (
734
820
  activeStaff = ctx.staff;
735
821
  parts.push('\\staff "' + activeStaff + '"');
736
822
  // Emit target staff clef if the event carries one or allStaffClefs knows it
737
- const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
823
+ const ctxClef = ctx.clef ||
824
+ (emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
738
825
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
739
826
  parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
740
827
  if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
@@ -763,7 +850,8 @@ const serializeVoice = (
763
850
  activeStaff = effectiveStaff;
764
851
  parts.push('\\staff "' + activeStaff + '"');
765
852
  // Emit the target staff's clef if it differs from what was last emitted for this staff
766
- const targetClef = allStaffClefs?.[activeStaff];
853
+ const targetClef =
854
+ emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
767
855
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
768
856
  parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
769
857
  if (emittedClefs) emittedClefs[activeStaff] = targetClef;
@@ -954,6 +1042,9 @@ const serializeMetadata = (metadata: Metadata): string => {
954
1042
  if (metadata.staves) {
955
1043
  lines.push('[staves "' + escapeString(metadata.staves) + '"]');
956
1044
  }
1045
+ if (metadata.measureLayout) {
1046
+ lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
1047
+ }
957
1048
  if (metadata.instruments) {
958
1049
  for (const [key, instr] of Object.entries(metadata.instruments)) {
959
1050
  let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
@@ -1048,9 +1139,26 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
1048
1139
  currentStaff = newStaff;
1049
1140
  }
1050
1141
 
1051
- // Join measures with bar, measure number comment, and double newline
1142
+ // Volta house-label comments (non-binding convention, like the `% n` measure
1143
+ // index): if a measure-layout directive is present, mark the first measure of
1144
+ // each alternate ending with a `% volta N` comment. Derived from the layout
1145
+ // AST; purely cosmetic, never parsed back.
1146
+ const voltaLabels = new Map<number, number>(); // 1-based measure index → ending number
1147
+ if (doc.metadata?.measureLayout) {
1148
+ try {
1149
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
1150
+ collectVoltaLabels(layout, voltaLabels);
1151
+ } catch { /* ignore malformed layout */ }
1152
+ }
1153
+
1154
+ // Join measures with bar, measure number comment, and double newline. A
1155
+ // `% volta N` label (if any) is prepended on its own line before the measure.
1052
1156
  const measuresOutput = measureStrs
1053
- .map((m, i) => m + ' | %' + (i + 1))
1157
+ .map((m, i) => {
1158
+ const label = voltaLabels.get(i + 1);
1159
+ const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
1160
+ return prefix + m + ' | %' + (i + 1);
1161
+ })
1054
1162
  .join('\n\n');
1055
1163
  parts.push(measuresOutput);
1056
1164
 
@@ -304,6 +304,10 @@ export interface Metadata {
304
304
  instrument?: string;
305
305
  genre?: string;
306
306
  staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
307
+ // Raw measure-layout (performance/repeat order) DSL, lotus-style, e.g.
308
+ // "2*[1..8]{9,10}, 11..16" (index-wise) or "s: 4 <2 6> 2" (segment-wise).
309
+ // Stored verbatim; parsing/expansion to MEI <expansion> is a later phase.
310
+ measureLayout?: string;
307
311
  // Per-staff / per-group instrument names, keyed by staff-layout group key (a single
308
312
  // staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
309
313
  // [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.