@k-l-lambda/lilylet 0.1.49 → 0.1.51

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.
Files changed (71) hide show
  1. package/lib/abc/abc.d.ts +102 -0
  2. package/lib/abc/abc.js +25 -0
  3. package/lib/abc/grammar.jison.js +1203 -0
  4. package/lib/abc/parser.d.ts +3 -0
  5. package/lib/abc/parser.js +6 -0
  6. package/lib/abcDecoder.d.ts +1 -0
  7. package/lib/abcDecoder.js +1 -0
  8. package/lib/grammar.jison.js +1 -1303
  9. package/lib/index.d.ts +1 -8
  10. package/lib/index.js +1 -10
  11. package/lib/lilylet/abcDecoder.d.ts +25 -0
  12. package/lib/lilylet/abcDecoder.js +1007 -0
  13. package/lib/lilylet/grammar.jison.js +1308 -0
  14. package/lib/lilylet/index.d.ts +10 -0
  15. package/lib/lilylet/index.js +10 -0
  16. package/lib/lilylet/lilypondDecoder.d.ts +29 -0
  17. package/lib/lilylet/lilypondDecoder.js +1053 -0
  18. package/lib/lilylet/lilypondEncoder.d.ts +34 -0
  19. package/lib/lilylet/lilypondEncoder.js +759 -0
  20. package/lib/lilylet/meiEncoder.d.ts +8 -0
  21. package/lib/lilylet/meiEncoder.js +1813 -0
  22. package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
  23. package/lib/lilylet/musicXmlDecoder.js +1195 -0
  24. package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
  25. package/lib/lilylet/musicXmlEncoder.js +701 -0
  26. package/lib/lilylet/musicXmlTypes.d.ts +199 -0
  27. package/lib/lilylet/musicXmlTypes.js +7 -0
  28. package/lib/lilylet/musicXmlUtils.d.ts +92 -0
  29. package/lib/lilylet/musicXmlUtils.js +469 -0
  30. package/lib/lilylet/parser.d.ts +3 -0
  31. package/lib/lilylet/parser.js +151 -0
  32. package/lib/lilylet/serializer.d.ts +11 -0
  33. package/lib/lilylet/serializer.js +702 -0
  34. package/lib/lilylet/types.d.ts +245 -0
  35. package/lib/lilylet/types.js +99 -0
  36. package/lib/lilypondDecoder.d.ts +1 -29
  37. package/lib/lilypondDecoder.js +1 -1006
  38. package/lib/lilypondEncoder.d.ts +1 -34
  39. package/lib/lilypondEncoder.js +1 -759
  40. package/lib/meiEncoder.d.ts +1 -8
  41. package/lib/meiEncoder.js +1 -1545
  42. package/lib/musicXmlDecoder.d.ts +1 -20
  43. package/lib/musicXmlDecoder.js +1 -1151
  44. package/lib/musicXmlEncoder.d.ts +1 -15
  45. package/lib/musicXmlEncoder.js +1 -666
  46. package/lib/musicXmlTypes.d.ts +1 -199
  47. package/lib/musicXmlTypes.js +1 -7
  48. package/lib/musicXmlUtils.d.ts +1 -81
  49. package/lib/musicXmlUtils.js +1 -435
  50. package/lib/parser.d.ts +1 -3
  51. package/lib/parser.js +1 -151
  52. package/lib/serializer.d.ts +1 -11
  53. package/lib/serializer.js +1 -650
  54. package/lib/types.d.ts +1 -244
  55. package/lib/types.js +1 -99
  56. package/package.json +2 -1
  57. package/source/abc/abc.jison +692 -0
  58. package/source/abc/abc.ts +176 -0
  59. package/source/abc/grammar.jison.js +1203 -0
  60. package/source/abc/parser.ts +12 -0
  61. package/source/lilylet/abcDecoder.ts +1121 -0
  62. package/source/lilylet/grammar.jison.js +195 -190
  63. package/source/lilylet/index.ts +4 -3
  64. package/source/lilylet/lilylet.jison +10 -3
  65. package/source/lilylet/lilypondDecoder.ts +91 -41
  66. package/source/lilylet/meiEncoder.ts +284 -0
  67. package/source/lilylet/musicXmlDecoder.ts +74 -27
  68. package/source/lilylet/musicXmlEncoder.ts +201 -146
  69. package/source/lilylet/musicXmlUtils.ts +46 -4
  70. package/source/lilylet/serializer.ts +75 -21
  71. package/source/lilylet/types.ts +1 -0
@@ -7,13 +7,14 @@ import * as meiEncoder from "./meiEncoder";
7
7
  import * as musicXmlDecoder from "./musicXmlDecoder";
8
8
  import * as lilypondEncoder from "./lilypondEncoder";
9
9
  import * as musicXmlEncoder from "./musicXmlEncoder";
10
+ import * as lilypondDecoder from "./lilypondDecoder";
11
+ import * as abcDecoder from "./abcDecoder";
10
12
 
11
13
  export {
12
14
  meiEncoder,
13
15
  musicXmlDecoder,
14
16
  lilypondEncoder,
15
17
  musicXmlEncoder,
18
+ lilypondDecoder,
19
+ abcDecoder,
16
20
  };
17
-
18
- // Note: lilypondDecoder is optional and requires @k-l-lambda/lotus
19
- // Import it directly from '@k-l-lambda/lilylet/lib/lilypondDecoder.js' if needed
@@ -165,6 +165,7 @@
165
165
  \[opus return 'HEADER_OPUS'
166
166
  \[instrument return 'HEADER_INSTRUMENT'
167
167
  \[genre return 'HEADER_GENRE'
168
+ \[auto\-beam return 'HEADER_AUTOBEAM'
168
169
  \] return ']'
169
170
 
170
171
  \"[^"]*\" return 'STRING'
@@ -307,6 +308,7 @@ header
307
308
  | HEADER_OPUS STRING ']' -> ({ opus: $2.slice(1, -1) })
308
309
  | HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2.slice(1, -1) })
309
310
  | HEADER_GENRE STRING ']' -> ({ genre: $2.slice(1, -1) })
311
+ | HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
310
312
  ;
311
313
 
312
314
  measures
@@ -507,10 +509,15 @@ tuplet_event
507
509
  : CMD_TIMES NUMBER '/' NUMBER '{' voice_events '}' -> tupletEvent(fraction(Number($2), Number($4)), $6.filter(e => e.type === 'note' || e.type === 'rest'))
508
510
  ;
509
511
 
512
+ tremolo_pitches
513
+ : pitch { $$ = [$1]; }
514
+ | chord -> $1
515
+ ;
516
+
510
517
  tremolo_event
511
- : CMD_REPEAT TREMOLO NUMBER '{' pitch duration pitch duration '}' %{ currentDuration = $6; $$ = tremoloEvent([$5], [$7], Number($3), $6.division); %}
512
- | CMD_REPEAT TREMOLO NUMBER '{' pitch duration pitch '}' %{ currentDuration = $6; $$ = tremoloEvent([$5], [$7], Number($3), $6.division); %}
513
- | CMD_REPEAT TREMOLO NUMBER '{' pitch pitch '}' -> tremoloEvent([$5], [$6], Number($3), currentDuration.division)
518
+ : CMD_REPEAT TREMOLO NUMBER '{' tremolo_pitches duration tremolo_pitches duration '}' %{ currentDuration = $6; $$ = tremoloEvent($5, $7, Number($3), $6.division); %}
519
+ | CMD_REPEAT TREMOLO NUMBER '{' tremolo_pitches duration tremolo_pitches '}' %{ currentDuration = $6; $$ = tremoloEvent($5, $7, Number($3), $6.division); %}
520
+ | CMD_REPEAT TREMOLO NUMBER '{' tremolo_pitches tremolo_pitches '}' -> tremoloEvent($5, $6, Number($3), currentDuration.division)
514
521
  ;
515
522
 
516
523
  post_events
@@ -641,7 +641,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
641
641
  }
642
642
  }
643
643
 
644
- // Process Chord (note or chord)
644
+ // Process Chord (note or chord, or positioned rest if isRest)
645
645
  if (term instanceof lilyParser.LilyTerms.Chord) {
646
646
  const pitches: Pitch[] = [];
647
647
 
@@ -656,40 +656,50 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
656
656
  }
657
657
 
658
658
  if (pitches.length > 0) {
659
- const { marks, harmonyText } = parsePostEvents(term.post_events);
660
-
661
- // Add beam marks
662
- if (term.beamOn) {
663
- marks.push({ markType: 'beam', start: true });
664
- } else if (term.beamOff) {
665
- marks.push({ markType: 'beam', start: false });
666
- }
659
+ // Check if this is a positioned rest (a\rest syntax)
660
+ if (term.isRest) {
661
+ const restEvent: RestEvent = {
662
+ type: 'rest',
663
+ duration: convertDuration(term.durationValue),
664
+ pitch: pitches[0], // Use first pitch for positioning
665
+ };
666
+ voice.events.push(restEvent);
667
+ } else {
668
+ const { marks, harmonyText } = parsePostEvents(term.post_events);
669
+
670
+ // Add beam marks
671
+ if (term.beamOn) {
672
+ marks.push({ markType: 'beam', start: true });
673
+ } else if (term.beamOff) {
674
+ marks.push({ markType: 'beam', start: false });
675
+ }
667
676
 
668
- // Add tie
669
- if (term.isTying) {
670
- marks.push({ markType: 'tie', start: true });
671
- }
677
+ // Add tie
678
+ if (term.isTying) {
679
+ marks.push({ markType: 'tie', start: true });
680
+ }
672
681
 
673
- const noteEvent: NoteEvent = {
674
- type: 'note',
675
- pitches,
676
- duration: convertDuration(term.durationValue),
677
- grace: context.inGrace || undefined,
678
- };
682
+ const noteEvent: NoteEvent = {
683
+ type: 'note',
684
+ pitches,
685
+ duration: convertDuration(term.durationValue),
686
+ grace: context.inGrace || undefined,
687
+ };
679
688
 
680
- if (marks.length > 0) {
681
- noteEvent.marks = marks;
682
- }
689
+ if (marks.length > 0) {
690
+ noteEvent.marks = marks;
691
+ }
683
692
 
684
- voice.events.push(noteEvent);
693
+ voice.events.push(noteEvent);
685
694
 
686
- // Add harmony event if detected (chord symbol encoded as \bold markup)
687
- if (harmonyText) {
688
- const harmonyEvent: HarmonyEvent = {
689
- type: 'harmony',
690
- text: harmonyText,
691
- };
692
- voice.events.push(harmonyEvent);
695
+ // Add harmony event if detected (chord symbol encoded as \bold markup)
696
+ if (harmonyText) {
697
+ const harmonyEvent: HarmonyEvent = {
698
+ type: 'harmony',
699
+ text: harmonyText,
700
+ };
701
+ voice.events.push(harmonyEvent);
702
+ }
693
703
  }
694
704
  }
695
705
  }
@@ -1100,30 +1110,70 @@ const extractMetadata = (lilyDocument: lilyParser.LilyDocument): Metadata | unde
1100
1110
  };
1101
1111
 
1102
1112
 
1113
+ // Dedupe consecutive context events in merged voice
1114
+ // This is needed because multiple tracks merged into one voice may have redundant context events
1115
+ const dedupeContextEvents = (events: Event[]): Event[] => {
1116
+ const result: Event[] = [];
1117
+ let lastStemDirection: string | undefined;
1118
+ let lastClef: string | undefined;
1119
+
1120
+ for (const e of events) {
1121
+ if (e.type === 'context') {
1122
+ const ctx = e as ContextChange;
1123
+
1124
+ // Dedupe stemDirection
1125
+ if ('stemDirection' in ctx) {
1126
+ if (ctx.stemDirection === lastStemDirection) continue;
1127
+ lastStemDirection = ctx.stemDirection;
1128
+ }
1129
+
1130
+ // Dedupe clef
1131
+ if ('clef' in ctx) {
1132
+ if (ctx.clef === lastClef) continue;
1133
+ lastClef = ctx.clef;
1134
+ }
1135
+ }
1136
+ result.push(e);
1137
+ }
1138
+ return result;
1139
+ };
1140
+
1141
+
1103
1142
  // Convert parsed measures to LilyletDoc
1104
1143
  const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadata): LilyletDoc => {
1105
1144
  const measures: Measure[] = parsedMeasures.map(pm => {
1106
1145
  // Filter out voices that only contain spacer rests and context changes
1107
1146
  const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
1108
1147
 
1109
- // Group voices by partIndex
1110
- const partMap = new Map<number, Array<{ staff: number; events: Event[] }>>();
1148
+ // Group voices by partIndex, then merge voices on the same staff
1149
+ const partMap = new Map<number, Map<number, Event[]>>();
1111
1150
  for (const v of filteredVoices) {
1112
1151
  const pi = v.partIndex || 1;
1113
1152
  if (!partMap.has(pi)) {
1114
- partMap.set(pi, []);
1153
+ partMap.set(pi, new Map());
1154
+ }
1155
+ const staffMap = partMap.get(pi)!;
1156
+
1157
+ // Merge events from voices on the same staff
1158
+ if (!staffMap.has(v.staff)) {
1159
+ staffMap.set(v.staff, []);
1115
1160
  }
1116
- partMap.get(pi)!.push({
1117
- staff: v.staff,
1118
- events: v.events,
1119
- });
1161
+ staffMap.get(v.staff)!.push(...v.events);
1120
1162
  }
1121
1163
 
1122
- // Convert to parts array (sorted by part index)
1164
+ // Convert to parts array (sorted by part index, then by staff)
1165
+ // Apply deduplication to merged events
1123
1166
  const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
1124
- const parts = partIndices.map(pi => ({
1125
- voices: partMap.get(pi)!,
1126
- }));
1167
+ const parts = partIndices.map(pi => {
1168
+ const staffMap = partMap.get(pi)!;
1169
+ const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
1170
+ return {
1171
+ voices: staffNums.map(staff => ({
1172
+ staff,
1173
+ events: dedupeContextEvents(staffMap.get(staff)!),
1174
+ })),
1175
+ };
1176
+ });
1127
1177
 
1128
1178
  // Fallback to single empty part if no voices
1129
1179
  const measure: Measure = {
@@ -17,10 +17,12 @@ import {
17
17
  OrnamentType,
18
18
  StemDirection,
19
19
  Mark,
20
+ Beam,
20
21
  HairpinType,
21
22
  PedalType,
22
23
  NavigationMarkType,
23
24
  Tempo,
25
+ Event,
24
26
  } from "./types";
25
27
 
26
28
 
@@ -522,6 +524,10 @@ const noteEventToMEI = (
522
524
  if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
523
525
  chordAttrs += ` staff="${noteOptions.staff}"`;
524
526
  }
527
+ if (noteOptions.tremolo) {
528
+ const stemMod = tremoloToStemMod(noteOptions.tremolo);
529
+ if (stemMod) chordAttrs += ` stem.mod="${stemMod}"`;
530
+ }
525
531
 
526
532
  let result = `${indent}<chord ${chordAttrs}>\n`;
527
533
 
@@ -1764,6 +1770,278 @@ const encodeScoreDef = (
1764
1770
  };
1765
1771
 
1766
1772
 
1773
+ // === Auto-beam logic ===
1774
+
1775
+ // Check if any NoteEvent in the document has a beam mark
1776
+ const docHasBeamMarks = (doc: LilyletDoc): boolean => {
1777
+ for (const measure of doc.measures) {
1778
+ for (const part of measure.parts) {
1779
+ for (const voice of part.voices) {
1780
+ for (const event of voice.events) {
1781
+ if (event.type === 'note') {
1782
+ const note = event as NoteEvent;
1783
+ if (note.marks) {
1784
+ for (const m of note.marks) {
1785
+ if (m.markType === 'beam') return true;
1786
+ }
1787
+ }
1788
+ } else if (event.type === 'tuplet') {
1789
+ const tuplet = event as TupletEvent;
1790
+ for (const e of tuplet.events) {
1791
+ if (e.type === 'note') {
1792
+ const note = e as NoteEvent;
1793
+ if (note.marks) {
1794
+ for (const m of note.marks) {
1795
+ if (m.markType === 'beam') return true;
1796
+ }
1797
+ }
1798
+ }
1799
+ }
1800
+ }
1801
+ }
1802
+ }
1803
+ }
1804
+ }
1805
+ return false;
1806
+ };
1807
+
1808
+ // Resolve whether auto-beam should be applied
1809
+ const resolveAutoBeam = (doc: LilyletDoc): boolean => {
1810
+ if (doc.metadata?.autoBeam === 'off') return false;
1811
+ if (doc.metadata?.autoBeam === 'on') return true;
1812
+ // 'auto' or undefined: auto-beam if no manual beam marks exist
1813
+ return !docHasBeamMarks(doc);
1814
+ };
1815
+
1816
+ // Compute beam group sizes in eighth-note units for a given time signature
1817
+ const getBeamGroups = (timeNum: number, timeDen: number): number[] => {
1818
+ // Compound meters (n/8 where n is divisible by 3, and n > 3)
1819
+ if (timeDen === 8 && timeNum % 3 === 0 && timeNum > 3) {
1820
+ const groupCount = timeNum / 3;
1821
+ return Array(groupCount).fill(3);
1822
+ }
1823
+
1824
+ // Specific common time signatures (LilyPond defaults)
1825
+ if (timeDen === 8 && timeNum === 3) return [3];
1826
+ if (timeDen === 4 && timeNum === 2) return [2, 2];
1827
+ if (timeDen === 4 && timeNum === 3) return [3, 3];
1828
+ if (timeDen === 4 && timeNum === 4) return [4, 4];
1829
+ if (timeDen === 2 && timeNum === 2) return [4, 4];
1830
+
1831
+ // Generic simple meters: each beat = 8/den eighths
1832
+ const eighthsPerBeat = 8 / timeDen;
1833
+ if (eighthsPerBeat >= 1) {
1834
+ return Array(timeNum).fill(eighthsPerBeat);
1835
+ }
1836
+
1837
+ // Fallback: one group for the whole measure
1838
+ const totalEighths = timeNum * 8 / timeDen;
1839
+ return [totalEighths];
1840
+ };
1841
+
1842
+ // Calculate duration in eighth-note units
1843
+ const durationInEighths = (division: number, dots: number, tupletRatio?: { numerator: number; denominator: number }): number => {
1844
+ // Base duration in eighths: 8 / division
1845
+ let dur = 8 / division;
1846
+ // Dot multiplier: 1 + 1/2 + 1/4 + ... = 2 - 1/2^dots
1847
+ if (dots > 0) {
1848
+ dur *= (2 - Math.pow(0.5, dots));
1849
+ }
1850
+ // Tuplet ratio: multiply by num/den (LilyPond semantics)
1851
+ if (tupletRatio) {
1852
+ dur *= tupletRatio.numerator / tupletRatio.denominator;
1853
+ }
1854
+ return dur;
1855
+ };
1856
+
1857
+ // Apply auto-beam to the document, mutating events' marks arrays in-place
1858
+ const applyAutoBeam = (doc: LilyletDoc): void => {
1859
+ // Track time signature across measures
1860
+ let timeNum = 4;
1861
+ let timeDen = 4;
1862
+
1863
+ // Get initial time signature
1864
+ if (doc.measures.length > 0 && doc.measures[0].timeSig) {
1865
+ timeNum = doc.measures[0].timeSig.numerator;
1866
+ timeDen = doc.measures[0].timeSig.denominator;
1867
+ }
1868
+
1869
+ for (const measure of doc.measures) {
1870
+ // Update time signature if changed
1871
+ if (measure.timeSig) {
1872
+ timeNum = measure.timeSig.numerator;
1873
+ timeDen = measure.timeSig.denominator;
1874
+ }
1875
+
1876
+ const beamGroups = getBeamGroups(timeNum, timeDen);
1877
+
1878
+ for (const part of measure.parts) {
1879
+ for (const voice of part.voices) {
1880
+ applyAutoBeamToVoice(voice.events, beamGroups);
1881
+ }
1882
+ }
1883
+ }
1884
+ };
1885
+
1886
+ // A beamable note reference: points to a NoteEvent that can receive beam marks
1887
+ interface BeamableNote {
1888
+ note: NoteEvent;
1889
+ position: number; // position in eighths at start of this note
1890
+ }
1891
+
1892
+ // Apply auto-beam to a single voice's events
1893
+ const applyAutoBeamToVoice = (events: Event[], beamGroups: number[]): void => {
1894
+ // Compute group boundary positions in eighths
1895
+ const groupBoundaries: number[] = [];
1896
+ let boundary = 0;
1897
+ for (const size of beamGroups) {
1898
+ boundary += size;
1899
+ groupBoundaries.push(boundary);
1900
+ }
1901
+ const totalMeasureEighths = boundary;
1902
+
1903
+ // Collect beamable notes with their positions
1904
+ let position = 0;
1905
+ const beamableRuns: BeamableNote[][] = [];
1906
+ let currentRun: BeamableNote[] = [];
1907
+
1908
+ // Helper: find which group index a position belongs to
1909
+ const getGroupIndex = (pos: number): number => {
1910
+ for (let i = 0; i < groupBoundaries.length; i++) {
1911
+ if (pos < groupBoundaries[i]) return i;
1912
+ }
1913
+ return groupBoundaries.length - 1;
1914
+ };
1915
+
1916
+ // Helper: flush current run into beamableRuns
1917
+ const flushRun = () => {
1918
+ if (currentRun.length >= 2) {
1919
+ beamableRuns.push(currentRun);
1920
+ }
1921
+ currentRun = [];
1922
+ };
1923
+
1924
+ for (const event of events) {
1925
+ if (event.type === 'note') {
1926
+ const note = event as NoteEvent;
1927
+ if (note.grace) continue; // skip grace notes
1928
+
1929
+ const dur = durationInEighths(note.duration.division, note.duration.dots);
1930
+
1931
+ if (note.duration.division >= 8) {
1932
+ // Beamable note
1933
+ const groupIdx = getGroupIndex(position);
1934
+ const noteEndPos = position + dur;
1935
+ const endGroupIdx = getGroupIndex(Math.min(noteEndPos - 0.001, totalMeasureEighths - 0.001));
1936
+
1937
+ // Note must start and end within the same group
1938
+ if (groupIdx === endGroupIdx) {
1939
+ // Check if current run is in the same group
1940
+ if (currentRun.length > 0) {
1941
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1942
+ if (lastGroupIdx !== groupIdx) {
1943
+ flushRun();
1944
+ }
1945
+ }
1946
+ currentRun.push({ note, position });
1947
+ } else {
1948
+ // Note spans group boundary — break
1949
+ flushRun();
1950
+ }
1951
+ } else {
1952
+ // Non-beamable note (quarter or longer) — break
1953
+ flushRun();
1954
+ }
1955
+
1956
+ position += dur;
1957
+ } else if (event.type === 'rest') {
1958
+ const rest = event as RestEvent;
1959
+ const dur = durationInEighths(rest.duration.division, rest.duration.dots);
1960
+ // Rests break beam groups
1961
+ flushRun();
1962
+ position += dur;
1963
+ } else if (event.type === 'tuplet') {
1964
+ const tuplet = event as TupletEvent;
1965
+ const ratio = tuplet.ratio; // LilyPond ratio: num/den
1966
+
1967
+ // Check if all inner notes are beamable (division >= 8)
1968
+ const innerNotes: { note: NoteEvent; dur: number }[] = [];
1969
+ let allBeamable = true;
1970
+ let tupletDur = 0;
1971
+
1972
+ for (const e of tuplet.events) {
1973
+ if (e.type === 'note') {
1974
+ const note = e as NoteEvent;
1975
+ if (note.grace) continue;
1976
+ const dur = durationInEighths(note.duration.division, note.duration.dots, ratio);
1977
+ innerNotes.push({ note, dur });
1978
+ tupletDur += dur;
1979
+ if (note.duration.division < 8) {
1980
+ allBeamable = false;
1981
+ }
1982
+ } else if (e.type === 'rest') {
1983
+ allBeamable = false;
1984
+ const dur = durationInEighths(e.duration.division, e.duration.dots, ratio);
1985
+ tupletDur += dur;
1986
+ }
1987
+ }
1988
+
1989
+ if (allBeamable && innerNotes.length > 0) {
1990
+ const groupIdx = getGroupIndex(position);
1991
+ const endGroupIdx = getGroupIndex(Math.min(position + tupletDur - 0.001, totalMeasureEighths - 0.001));
1992
+
1993
+ if (groupIdx === endGroupIdx) {
1994
+ // Tuplet fits within one group — add inner notes to current run
1995
+ if (currentRun.length > 0) {
1996
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1997
+ if (lastGroupIdx !== groupIdx) {
1998
+ flushRun();
1999
+ }
2000
+ }
2001
+ let innerPos = position;
2002
+ for (const { note, dur } of innerNotes) {
2003
+ currentRun.push({ note, position: innerPos });
2004
+ innerPos += dur;
2005
+ }
2006
+ } else {
2007
+ flushRun();
2008
+ }
2009
+ } else {
2010
+ flushRun();
2011
+ }
2012
+
2013
+ position += tupletDur;
2014
+ } else if (event.type === 'context' || event.type === 'pitchReset' || event.type === 'barline' || event.type === 'harmony' || event.type === 'markup') {
2015
+ // Non-musical events: don't advance position, don't break beams
2016
+ continue;
2017
+ } else if (event.type === 'tremolo') {
2018
+ // Tremolo breaks beams
2019
+ const trem = event as TremoloEvent;
2020
+ // Total duration = count * 2 * (1/division) in whole notes
2021
+ // In eighths: count * 2 * (8/division)
2022
+ const dur = trem.count * 2 * (8 / trem.division);
2023
+ flushRun();
2024
+ position += dur;
2025
+ }
2026
+ }
2027
+
2028
+ // Flush any remaining run
2029
+ flushRun();
2030
+
2031
+ // Apply beam marks to collected runs
2032
+ for (const run of beamableRuns) {
2033
+ const first = run[0].note;
2034
+ const last = run[run.length - 1].note;
2035
+
2036
+ if (!first.marks) first.marks = [];
2037
+ first.marks.push({ markType: 'beam', start: true } as Beam);
2038
+
2039
+ if (!last.marks) last.marks = [];
2040
+ last.marks.push({ markType: 'beam', start: false } as Beam);
2041
+ }
2042
+ };
2043
+
2044
+
1767
2045
  // Main encode function
1768
2046
  const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1769
2047
  const indent = options.indent || " ";
@@ -1797,6 +2075,12 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1797
2075
 
1798
2076
  const keySig = KEY_SIGS[currentKey] || "0";
1799
2077
 
2078
+ // Apply auto-beam if needed (before encoding so beam marks are picked up by encodeLayer)
2079
+ const shouldAutoBeam = resolveAutoBeam(doc);
2080
+ if (shouldAutoBeam) {
2081
+ applyAutoBeam(doc);
2082
+ }
2083
+
1800
2084
  // Build MEI document
1801
2085
  const xmlDecl = options.xmlDeclaration !== false
1802
2086
  ? '<?xml version="1.0" encoding="UTF-8"?>\n'