@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.
@@ -51,22 +51,46 @@
51
51
  mode,
52
52
  });
53
53
 
54
- const voice = (staff, events) => ({
55
- staff: staff || 1,
56
- events,
57
- });
54
+ const voice = (staff, events) => {
55
+ // Use the first \staff "N" context event that appears before any musical event
56
+ // (notes/rests/tuplets) as the authoritative voice.staff.
57
+ // Skip pitchReset events (from NEWLINE tokens at voice-line boundaries).
58
+ // This ensures \staff "1" \times ... \staff "2" ... gives voice.staff=1,
59
+ // not 2 (the final currentStaff value), while \staff "2" c1 gives voice.staff=2.
60
+ let leadingStaff = null;
61
+ for (const e of events) {
62
+ if (!e) continue;
63
+ if (e.type === 'pitchReset') continue;
64
+ if (e.type === 'context') {
65
+ if (e.staff != null) { leadingStaff = e.staff; break; }
66
+ continue; // skip non-staff context events (key, clef, time, etc.)
67
+ }
68
+ break; // first musical/structural event — stop
69
+ }
70
+ return {
71
+ staff: leadingStaff != null ? leadingStaff : 1,
72
+ events,
73
+ };
74
+ };
58
75
 
59
76
  const part = (voices, name) => ({
60
77
  name: name || undefined,
61
78
  voices,
62
79
  });
63
80
 
64
- const measure = (parts, key, timeSig, partial) => ({
65
- key: key || undefined,
66
- timeSig: timeSig || undefined,
67
- parts,
68
- partial: partial || undefined,
69
- });
81
+ const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
82
+ const hasMusicalContent = (parts) =>
83
+ parts.some(p => p.voices.some(v => v.events.some(e => e && MUSICAL.has(e.type))));
84
+
85
+ const measure = (parts, key, timeSig, partial) => {
86
+ if (!hasMusicalContent(parts)) return null;
87
+ return {
88
+ key: key || undefined,
89
+ timeSig: timeSig || undefined,
90
+ parts,
91
+ partial: partial || undefined,
92
+ };
93
+ };
70
94
 
71
95
  const tupletEvent = (ratio, events) => ({
72
96
  type: 'tuplet',
@@ -74,6 +98,12 @@
74
98
  events,
75
99
  });
76
100
 
101
+ const timesEvent = (ratio, events) => ({
102
+ type: 'times',
103
+ ratio,
104
+ events,
105
+ });
106
+
77
107
  const tremoloEvent = (pitchA, pitchB, count, division) => ({
78
108
  type: 'tremolo',
79
109
  pitchA,
@@ -131,6 +161,9 @@
131
161
  let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
132
162
  let currentOttava = 0; // Current ottava level, resets on newline
133
163
 
164
+ // \partial warnings accumulated during parse; reset before each parse
165
+ let parseWarnings = [];
166
+
134
167
  // Reset parser state - call before each parse
135
168
  const resetParserState = () => {
136
169
  currentStaff = 1;
@@ -139,10 +172,40 @@
139
172
  currentDuration = { division: 4, dots: 0 };
140
173
  numericTimeSignature = false;
141
174
  currentOttava = 0;
175
+ parseWarnings = [];
142
176
  };
143
177
 
144
- // Export reset function
178
+ // Export reset function and warning accessors
145
179
  parser.resetState = resetParserState;
180
+ parser.getWarnings = () => parseWarnings;
181
+
182
+ // Duration ticks helper for \partial validation (TPQN=480)
183
+ const durationTicks = (dur) => {
184
+ let t = 1920 / dur.division; // 1920 = whole note ticks
185
+ let dot = t / 2;
186
+ for (let i = 0; i < (dur.dots || 0); i++) { t += dot; dot /= 2; }
187
+ return Math.round(t);
188
+ };
189
+
190
+ // Sum ticks for an event list (notes/rests only, skip graces)
191
+ const voiceEventTicks = (events) => {
192
+ let total = 0;
193
+ for (const e of events) {
194
+ if (!e) continue;
195
+ if (e.type === 'note' || e.type === 'rest') {
196
+ if (e.grace) continue;
197
+ total += durationTicks(e.duration);
198
+ } else if (e.type === 'tuplet' || e.type === 'times') {
199
+ const factor = e.ratio.numerator / e.ratio.denominator;
200
+ for (const inner of e.events || []) {
201
+ if (inner && (inner.type === 'note' || inner.type === 'rest') && !inner.grace) {
202
+ total += Math.round(durationTicks(inner.duration) * factor);
203
+ }
204
+ }
205
+ }
206
+ }
207
+ return total;
208
+ };
146
209
  %}
147
210
 
148
211
 
@@ -173,12 +236,14 @@
173
236
  "\\clef" return 'CMD_CLEF'
174
237
  "\\key" return 'CMD_KEY'
175
238
  "\\time" return 'CMD_TIME'
239
+ "\\partial" return 'CMD_PARTIAL'
176
240
  "\\numericTimeSignature" return 'CMD_NUMERIC_TIME_SIG'
177
241
  "\\defaultTimeSignature" return 'CMD_DEFAULT_TIME_SIG'
178
242
  "\\tempo" return 'CMD_TEMPO'
179
243
  "\\staff" return 'CMD_STAFF'
180
244
  "\\grace" return 'CMD_GRACE'
181
245
  "\\times" return 'CMD_TIMES'
246
+ "\\tuplet" return 'CMD_TUPLET'
182
247
  "\\repeat" return 'CMD_REPEAT'
183
248
  "\\ottava" return 'CMD_OTTAVA'
184
249
  "\\stemUp" return 'CMD_STEMUP'
@@ -313,13 +378,41 @@ header
313
378
  ;
314
379
 
315
380
  measures
316
- : measure_content { $$ = [$1]; }
317
- | measures '|' measure_content { $$ = $1.concat([$3]); }
381
+ : measure_content { $$ = $1 ? [$1] : []; }
382
+ | measures '|' measure_content { $$ = $3 ? $1.concat([$3]) : $1; }
318
383
  | measures '|' { $$ = $1; }
319
384
  ;
320
385
 
321
386
  measure_content
322
- : parts -> measure($1, currentKey, currentTimeSig)
387
+ : parts %{
388
+ // Check \partial declarations: warn if declared duration ≠ actual voice ticks
389
+ let partialDur = null;
390
+ outer: for (const p of $1) {
391
+ for (const v of p.voices) {
392
+ for (const e of v.events) {
393
+ if (e && e.type === 'context' && e.partial) {
394
+ partialDur = e.partial;
395
+ break outer;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ if (partialDur) {
401
+ const declared = durationTicks(partialDur);
402
+ for (const p of $1) {
403
+ for (const v of p.voices) {
404
+ const actual = voiceEventTicks(v.events);
405
+ if (actual > 0 && actual !== declared) {
406
+ const durStr = partialDur.division + '.'.repeat(partialDur.dots || 0);
407
+ const msg = `\\partial ${durStr}: declared ${declared} ticks but voice has ${actual} ticks`;
408
+ console.warn('[lilylet] ' + msg);
409
+ parseWarnings.push({ type: 'partial-mismatch', message: msg, declared, actual });
410
+ }
411
+ }
412
+ }
413
+ }
414
+ $$ = measure($1, currentKey, currentTimeSig, partialDur ? true : undefined);
415
+ %}
323
416
  ;
324
417
 
325
418
  parts
@@ -429,6 +522,7 @@ context_event
429
522
  : clef_cmd -> contextChange({ clef: $1 })
430
523
  | key_cmd -> contextChange({ key: $1 })
431
524
  | time_cmd -> contextChange({ time: $1 })
525
+ | partial_cmd -> contextChange({ partial: $1 })
432
526
  | tempo_cmd -> contextChange({ tempo: $1 })
433
527
  | staff_cmd -> contextChange({ staff: $1 })
434
528
  | ottava_cmd -> contextChange({ ottava: $1 })
@@ -480,6 +574,10 @@ default_time_sig_cmd
480
574
  : CMD_DEFAULT_TIME_SIG %{ numericTimeSignature = false; $$ = null; %}
481
575
  ;
482
576
 
577
+ partial_cmd
578
+ : CMD_PARTIAL duration -> $2
579
+ ;
580
+
483
581
  tempo_cmd
484
582
  : CMD_TEMPO STRING duration '=' NUMBER -> ({ text: $2.slice(1, -1), beat: $3, bpm: Number($5) })
485
583
  | CMD_TEMPO STRING -> ({ text: $2.slice(1, -1) })
@@ -509,7 +607,8 @@ grace_event
509
607
  ;
510
608
 
511
609
  tuplet_event
512
- : CMD_TIMES NUMBER '/' NUMBER '{' voice_events '}' -> tupletEvent(fraction(Number($2), Number($4)), $6.filter(e => e.type === 'note' || e.type === 'rest'))
610
+ : CMD_TIMES NUMBER '/' NUMBER '{' voice_events '}' -> timesEvent(fraction(Number($2), Number($4)), $6.filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'context'))
611
+ | CMD_TUPLET NUMBER '/' NUMBER '{' voice_events '}' -> tupletEvent(fraction(Number($4), Number($2)), $6.filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'context'))
513
612
  ;
514
613
 
515
614
  tremolo_pitches
@@ -489,6 +489,42 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
489
489
 
490
490
  const interpreter = lilyDocument.interpret();
491
491
 
492
+ // Pre-compute partIndex for each track using staff-number-sequence heuristic.
493
+ // When staff numbers reset (decrease), a new part starts — this handles multi-PianoStaff
494
+ // scores where both pianos reuse the same staff names ("1" and "2").
495
+ // Scores that embed partIndex in the staff name ("1_1", "1_2", "2_1") are handled
496
+ // by parseStaffName; this heuristic only kicks in for plain numeric names.
497
+ let _seqPart = 1;
498
+ let _seqMaxStaff = 0;
499
+ const _trackPartIndices: number[] = interpreter.layoutMusic.musicTracks.map((track: any) => {
500
+ const staffName: string | undefined = track.contextDict?.Staff;
501
+ if (staffName && /^\d+_\d+$/.test(staffName)) {
502
+ // Staff name encodes partIndex explicitly — don't apply heuristic
503
+ return parseInt(staffName.split('_')[0], 10);
504
+ }
505
+ // PianoStaff is present (even as empty string "") when inside a grand-staff group;
506
+ // undefined means standalone instrument → different part from grand-staff tracks.
507
+ const hasPianoStaff = (track.contextDict?.PianoStaff !== undefined);
508
+ const staffNum = parseInt(staffName || '1', 10) || 1;
509
+
510
+ if (hasPianoStaff) {
511
+ // Grand-staff: new part if transitioning from standalone or staff resets
512
+ if (_seqMaxStaff > 0 && staffNum < _seqMaxStaff) {
513
+ _seqPart++;
514
+ _seqMaxStaff = 0;
515
+ } else if (_seqMaxStaff === -1) {
516
+ // Transitioning from a standalone group
517
+ _seqPart++;
518
+ _seqMaxStaff = 0;
519
+ }
520
+ _seqMaxStaff = Math.max(_seqMaxStaff, staffNum);
521
+ } else {
522
+ // Standalone: mark with -1 so next grand-staff group increments
523
+ _seqMaxStaff = -1;
524
+ }
525
+ return _seqPart;
526
+ });
527
+
492
528
  interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
493
529
  const appendStaff = (staffName: string): void => {
494
530
  if (!staffNames.includes(staffName)) {
@@ -498,15 +534,20 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
498
534
 
499
535
  // Parse staff name to extract partIndex and staff number
500
536
  // Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
501
- // Falls back to partIndex=1 if format doesn't match
537
+ // Falls back to positional ordering for non-numeric names (e.g., "upper"→1, "lower"→2)
502
538
  const parseStaffName = (name: string): { partIndex: number; staffNum: number } => {
503
539
  const match = name.match(/^(\d+)_(\d+)$/);
504
540
  if (match) {
505
541
  return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
506
542
  }
507
- // Fallback: single part, staff number from name or 1
508
543
  const num = parseInt(name, 10);
509
- return { partIndex: 1, staffNum: isNaN(num) ? 1 : num };
544
+ if (!isNaN(num)) {
545
+ return { partIndex: 1, staffNum: num };
546
+ }
547
+ // Non-numeric name: assign staffNum by order of first appearance in staffNames
548
+ appendStaff(name);
549
+ const idx = staffNames.indexOf(name);
550
+ return { partIndex: 1, staffNum: idx + 1 };
510
551
  };
511
552
 
512
553
  // Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
@@ -515,10 +556,14 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
515
556
  if (initialStaffName) {
516
557
  appendStaff(initialStaffName);
517
558
  }
518
- const parsedStaff = initialStaffName ? parseStaffName(initialStaffName) : { partIndex: 1, staffNum: 1 };
559
+ // Empty string staff name ("") means unnamed staff treat as staff 1 within its part
560
+ const parsedStaff = (initialStaffName != null && initialStaffName !== '')
561
+ ? parseStaffName(initialStaffName)
562
+ : { partIndex: 1, staffNum: 1 };
519
563
  // Use these as fixed values for this track - don't update from context.staffName
520
564
  const trackStaff = parsedStaff.staffNum;
521
- const trackPartIndex = parsedStaff.partIndex;
565
+ // Use sequence-based partIndex (detects multi-PianoStaff via staff number reset)
566
+ const trackPartIndex = _trackPartIndices[vi] ?? parsedStaff.partIndex;
522
567
 
523
568
  // Track emitted context events across measures for this voice
524
569
  let lastKey: number | undefined = undefined; // Track value changes (key fifths)
@@ -526,6 +571,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
526
571
  let lastClef: Clef | undefined = undefined; // Track value changes
527
572
  let lastOttava: number | undefined = undefined; // Track value changes
528
573
  let lastStemDirection: string | undefined = undefined; // Track value changes
574
+ let partialEmitted = false; // Emit \partial context once per track
529
575
 
530
576
  const context = new lilyParser.TrackContext(undefined, {
531
577
  listener: (term: lilyParser.BaseTerm, context: lilyParser.TrackContext) => {
@@ -553,11 +599,12 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
553
599
  }
554
600
  const voice = measure.voices[vi];
555
601
 
556
- // Update key/time from context on music events
602
+ // Update key/time/partial from context on music events
557
603
  if (term instanceof lilyParser.MusicEvent ||
558
604
  term instanceof lilyParser.LilyTerms.StemDirection ||
559
605
  term instanceof lilyParser.LilyTerms.OctaveShift ||
560
- term instanceof lilyParser.LilyTerms.Change) {
606
+ term instanceof lilyParser.LilyTerms.Change ||
607
+ term instanceof lilyParser.LilyTerms.Partial) {
561
608
 
562
609
  if (context.key && measure.key === null) {
563
610
  measure.key = context.key.key;
@@ -573,6 +620,21 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
573
620
  }
574
621
  }
575
622
 
623
+ // Emit \partial context event when the Partial term fires (before partialDuration is cleared)
624
+ if (term instanceof lilyParser.LilyTerms.Partial && context.partialDuration && !partialEmitted) {
625
+ const mag = (context.partialDuration as any).magnitude;
626
+ const WHOLE = 1920;
627
+ let division = 1, dots = 0;
628
+ for (const div of [1, 2, 4, 8, 16, 32, 64]) {
629
+ const base = WHOLE / div;
630
+ if (Math.abs(mag - base) < 1) { division = div; dots = 0; break; }
631
+ if (Math.abs(mag - Math.round(base * 1.5)) < 1) { division = div; dots = 1; break; }
632
+ if (Math.abs(mag - Math.round(base * 1.75)) < 1) { division = div; dots = 2; break; }
633
+ }
634
+ voice.events.push({ type: 'context', partial: { division, dots } });
635
+ partialEmitted = true;
636
+ }
637
+
576
638
  // Handle music events
577
639
  if (term instanceof lilyParser.MusicEvent) {
578
640
  // Staff is fixed per track (from track definition)
@@ -706,14 +768,18 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
706
768
  }
707
769
  // Process Rest
708
770
  else if (term instanceof lilyParser.LilyTerms.Rest) {
709
- const restEvent: RestEvent = {
710
- type: 'rest',
711
- duration: convertDuration(term.durationValue),
712
- fullMeasure: (term.name === 'R') || undefined,
713
- invisible: term.isSpacer || undefined,
714
- };
771
+ // Ignore spacer rests inside grace contexts (e.g. \acciaccatura s8,
772
+ // \grace s4 — these are notation-only placeholders with no musical content)
773
+ if (!(term.isSpacer && context.inGrace)) {
774
+ const restEvent: RestEvent = {
775
+ type: 'rest',
776
+ duration: convertDuration(term.durationValue),
777
+ fullMeasure: (term.name === 'R') || undefined,
778
+ invisible: term.isSpacer || undefined,
779
+ };
715
780
 
716
- voice.events.push(restEvent);
781
+ voice.events.push(restEvent);
782
+ }
717
783
  }
718
784
  }
719
785
  // Handle standalone stem direction (emit when value changes)
@@ -834,46 +900,71 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
834
900
  // Handle tuplet
835
901
  // Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
836
902
  // remove the already-added notes and wrap them in a TupletEvent
837
- else if (termAny.proto === 'Tuplet') {
838
- const ratioStr = termAny.args?.[0]; // e.g., "3/2"
839
- const body = termAny.args?.[1]?.body || [];
903
+ else if (termAny.proto === 'Tuplet' || termAny.proto === 'Times') {
904
+ const isTimes = termAny.proto === 'Times';
905
+ const ratioStr = termAny.args?.[0]; // "3/2" for \tuplet, "2/3" for \times
906
+ // \tuplet supports an optional base-duration arg: \tuplet 3/2 4 { notes }
907
+ // making args = [ratio, baseDur?, body]. Use the last arg (= music block),
908
+ // matching what lotus Tuplet.music getter does: this.args[this.args.length-1]
909
+ const body = termAny.args?.[termAny.args.length - 1]?.body || [];
840
910
 
841
911
  if (ratioStr && body.length > 0) {
842
- // Parse ratio string
843
912
  const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
844
913
  if (ratioMatch) {
845
914
  const [, num, denom] = ratioMatch;
846
- const ratio: Fraction = {
847
- numerator: parseInt(denom, 10), // Swapped: lilylet uses actual/normal
848
- denominator: parseInt(num, 10),
915
+ // \tuplet 3/2: divider = 3/2, lilylet ratio = 2/3 → swap
916
+ // \times 2/3: factor = 2/3, lilylet ratio = 2/3 → no swap
917
+ const ratio: Fraction = isTimes
918
+ ? { numerator: parseInt(num, 10), denominator: parseInt(denom, 10) }
919
+ : { numerator: parseInt(denom, 10), denominator: parseInt(num, 10) };
920
+
921
+ // Count sounding (non-grace) notes/rests in the tuplet body,
922
+ // recursing into AfterGrace.body (main note) but not AfterGrace.grace.
923
+ // Grace / Acciaccatura / Appoggiatura blocks count as 0.
924
+ const countSounding = (items: any[]): number => {
925
+ let n = 0;
926
+ for (const item of items) {
927
+ if (!item) continue;
928
+ switch (item.proto) {
929
+ case 'Chord': case 'Rest': n++; break;
930
+ case 'AfterGrace': {
931
+ // args[0] = main note, args[1] = after-grace notes (0-dur, skip)
932
+ const main = item.args?.[0];
933
+ if (main?.proto === 'Chord' || main?.proto === 'Rest') n++;
934
+ else if (main?.body) n += countSounding(main.body);
935
+ break;
936
+ }
937
+ case 'Grace': case 'Acciaccatura': case 'Appoggiatura': break;
938
+ default: if (item.body) n += countSounding(item.body); break;
939
+ }
940
+ }
941
+ return n;
849
942
  };
943
+ const noteCount = countSounding(body);
850
944
 
851
- // Count how many note/rest events are in the tuplet body
852
- const noteCount = body.filter((item: any) =>
853
- item.proto === 'Chord' || item.proto === 'Rest'
854
- ).length;
855
-
856
- // Remove the last noteCount note/rest events from voice.events
857
- // (they were already added by the Chord/Rest handlers)
858
945
  const tupletEvents: (NoteEvent | RestEvent)[] = [];
859
946
  let removed = 0;
947
+ // Pop notes/rests from voice.events, skipping context events.
948
+ // Grace notes are moved inside the tuplet but do NOT consume a
949
+ // sounding-note slot (they have 0 duration in the time signature).
860
950
  while (removed < noteCount && voice.events.length > 0) {
861
951
  const lastEvent = voice.events[voice.events.length - 1];
862
952
  if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
863
953
  tupletEvents.unshift(voice.events.pop()! as NoteEvent | RestEvent);
864
- removed++;
954
+ if (!(lastEvent as NoteEvent).grace) removed++;
955
+ } else if (lastEvent.type === 'context' || lastEvent.type === 'pitchReset') {
956
+ // Context event between tuplet notes — move it inside tuplet too
957
+ tupletEvents.unshift(voice.events.pop()! as any);
865
958
  } else {
866
- break; // Stop if we hit a non-note/rest event
959
+ break;
867
960
  }
868
961
  }
869
962
 
870
963
  if (tupletEvents.length > 0) {
871
- const tupletEvent: TupletEvent = {
872
- type: 'tuplet',
873
- ratio,
874
- events: tupletEvents,
875
- };
876
- voice.events.push(tupletEvent);
964
+ voice.events.push(isTimes
965
+ ? { type: 'times', ratio, events: tupletEvents } as any
966
+ : { type: 'tuplet', ratio, events: tupletEvents } as TupletEvent
967
+ );
877
968
  }
878
969
  }
879
970
  }
@@ -924,6 +1015,52 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
924
1015
  });
925
1016
 
926
1017
  context.execute(track.music);
1018
+
1019
+ // Post-process: carry staff state across measure boundaries for this voice.
1020
+ // If measure N ends on staff 2 (via \change Staff), measure N+1 must begin
1021
+ // with context { staff: 2 } so notes at the start of that measure serialize
1022
+ // on the correct staff.
1023
+ {
1024
+ const measureIndices = Array.from(measureMap.keys()).sort((a, b) => a - b);
1025
+ let carryStaff = trackStaff;
1026
+ for (const mi of measureIndices) {
1027
+ const voice = measureMap.get(mi)?.voices[vi];
1028
+ if (!voice) continue;
1029
+
1030
+ // Prepend carry-over event if previous measure ended on a different staff,
1031
+ // but skip if the voice's first explicit staff event already resets to
1032
+ // trackStaff — the carry-over would be immediately cancelled (no-op).
1033
+ if (carryStaff !== trackStaff) {
1034
+ // Suppress carry-over if the first staff event resets to trackStaff
1035
+ // AND no musical events (notes/rests/etc.) precede it — meaning the
1036
+ // carry-over would be immediately cancelled with no effect.
1037
+ const firstStaffCtxIdx = voice.events.findIndex(
1038
+ e => e.type === 'context' && (e as any).staff != null
1039
+ );
1040
+ const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
1041
+ const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
1042
+ voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
1043
+ const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
1044
+ (voice.events[firstStaffCtxIdx] as any).staff === trackStaff &&
1045
+ !hasMusicBeforeFirstStaff;
1046
+ if (!immediatelyCancelled) {
1047
+ voice.events.unshift({ type: 'context', staff: carryStaff } as any);
1048
+ }
1049
+ }
1050
+
1051
+ // Update carryStaff from this measure's events (including inside tuplets)
1052
+ const scanStaff = (events: any[]): void => {
1053
+ for (const e of events) {
1054
+ if (e.type === 'context' && (e as any).staff) {
1055
+ carryStaff = (e as any).staff;
1056
+ } else if ((e.type === 'tuplet' || e.type === 'times') && e.events) {
1057
+ scanStaff(e.events);
1058
+ }
1059
+ }
1060
+ };
1061
+ scanStaff(voice.events);
1062
+ }
1063
+ }
927
1064
  });
928
1065
 
929
1066
  // Filter out empty voices and convert to array, sorted by measure number
@@ -944,6 +1081,7 @@ const hasRealContent = (events: Event[]): boolean => {
944
1081
  if (e.type === 'note') return true;
945
1082
  if (e.type === 'rest' && !(e as RestEvent).invisible) return true;
946
1083
  if (e.type === 'tuplet') return true;
1084
+ if (e.type === 'times') return true;
947
1085
  if (e.type === 'tremolo') return true;
948
1086
  return false;
949
1087
  });