@k-l-lambda/lilylet 0.1.48 → 0.1.50

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 +1808 -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 +653 -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 +170 -165
  63. package/source/lilylet/index.ts +4 -3
  64. package/source/lilylet/lilylet.jison +2 -0
  65. package/source/lilylet/lilypondDecoder.ts +92 -42
  66. package/source/lilylet/meiEncoder.ts +280 -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 +3 -0
  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
@@ -10,7 +10,7 @@ import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
10
10
 
11
11
  // Import pre-compiled LilyPond parser (browser-compatible)
12
12
  // @ts-ignore - CommonJS module
13
- import * as lilypondParser from "@k-l-lambda/lotus/lib/lilyParser.js";
13
+ import * as lilypondParser from "@k-l-lambda/lotus/lib.browser/lib/lilyParser.js";
14
14
 
15
15
  import {
16
16
  LilyletDoc,
@@ -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
 
@@ -1764,6 +1766,278 @@ const encodeScoreDef = (
1764
1766
  };
1765
1767
 
1766
1768
 
1769
+ // === Auto-beam logic ===
1770
+
1771
+ // Check if any NoteEvent in the document has a beam mark
1772
+ const docHasBeamMarks = (doc: LilyletDoc): boolean => {
1773
+ for (const measure of doc.measures) {
1774
+ for (const part of measure.parts) {
1775
+ for (const voice of part.voices) {
1776
+ for (const event of voice.events) {
1777
+ if (event.type === 'note') {
1778
+ const note = event as NoteEvent;
1779
+ if (note.marks) {
1780
+ for (const m of note.marks) {
1781
+ if (m.markType === 'beam') return true;
1782
+ }
1783
+ }
1784
+ } else if (event.type === 'tuplet') {
1785
+ const tuplet = event as TupletEvent;
1786
+ for (const e of tuplet.events) {
1787
+ if (e.type === 'note') {
1788
+ const note = e as NoteEvent;
1789
+ if (note.marks) {
1790
+ for (const m of note.marks) {
1791
+ if (m.markType === 'beam') return true;
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ }
1797
+ }
1798
+ }
1799
+ }
1800
+ }
1801
+ return false;
1802
+ };
1803
+
1804
+ // Resolve whether auto-beam should be applied
1805
+ const resolveAutoBeam = (doc: LilyletDoc): boolean => {
1806
+ if (doc.metadata?.autoBeam === 'off') return false;
1807
+ if (doc.metadata?.autoBeam === 'on') return true;
1808
+ // 'auto' or undefined: auto-beam if no manual beam marks exist
1809
+ return !docHasBeamMarks(doc);
1810
+ };
1811
+
1812
+ // Compute beam group sizes in eighth-note units for a given time signature
1813
+ const getBeamGroups = (timeNum: number, timeDen: number): number[] => {
1814
+ // Compound meters (n/8 where n is divisible by 3, and n > 3)
1815
+ if (timeDen === 8 && timeNum % 3 === 0 && timeNum > 3) {
1816
+ const groupCount = timeNum / 3;
1817
+ return Array(groupCount).fill(3);
1818
+ }
1819
+
1820
+ // Specific common time signatures (LilyPond defaults)
1821
+ if (timeDen === 8 && timeNum === 3) return [3];
1822
+ if (timeDen === 4 && timeNum === 2) return [2, 2];
1823
+ if (timeDen === 4 && timeNum === 3) return [3, 3];
1824
+ if (timeDen === 4 && timeNum === 4) return [4, 4];
1825
+ if (timeDen === 2 && timeNum === 2) return [4, 4];
1826
+
1827
+ // Generic simple meters: each beat = 8/den eighths
1828
+ const eighthsPerBeat = 8 / timeDen;
1829
+ if (eighthsPerBeat >= 1) {
1830
+ return Array(timeNum).fill(eighthsPerBeat);
1831
+ }
1832
+
1833
+ // Fallback: one group for the whole measure
1834
+ const totalEighths = timeNum * 8 / timeDen;
1835
+ return [totalEighths];
1836
+ };
1837
+
1838
+ // Calculate duration in eighth-note units
1839
+ const durationInEighths = (division: number, dots: number, tupletRatio?: { numerator: number; denominator: number }): number => {
1840
+ // Base duration in eighths: 8 / division
1841
+ let dur = 8 / division;
1842
+ // Dot multiplier: 1 + 1/2 + 1/4 + ... = 2 - 1/2^dots
1843
+ if (dots > 0) {
1844
+ dur *= (2 - Math.pow(0.5, dots));
1845
+ }
1846
+ // Tuplet ratio: multiply by num/den (LilyPond semantics)
1847
+ if (tupletRatio) {
1848
+ dur *= tupletRatio.numerator / tupletRatio.denominator;
1849
+ }
1850
+ return dur;
1851
+ };
1852
+
1853
+ // Apply auto-beam to the document, mutating events' marks arrays in-place
1854
+ const applyAutoBeam = (doc: LilyletDoc): void => {
1855
+ // Track time signature across measures
1856
+ let timeNum = 4;
1857
+ let timeDen = 4;
1858
+
1859
+ // Get initial time signature
1860
+ if (doc.measures.length > 0 && doc.measures[0].timeSig) {
1861
+ timeNum = doc.measures[0].timeSig.numerator;
1862
+ timeDen = doc.measures[0].timeSig.denominator;
1863
+ }
1864
+
1865
+ for (const measure of doc.measures) {
1866
+ // Update time signature if changed
1867
+ if (measure.timeSig) {
1868
+ timeNum = measure.timeSig.numerator;
1869
+ timeDen = measure.timeSig.denominator;
1870
+ }
1871
+
1872
+ const beamGroups = getBeamGroups(timeNum, timeDen);
1873
+
1874
+ for (const part of measure.parts) {
1875
+ for (const voice of part.voices) {
1876
+ applyAutoBeamToVoice(voice.events, beamGroups);
1877
+ }
1878
+ }
1879
+ }
1880
+ };
1881
+
1882
+ // A beamable note reference: points to a NoteEvent that can receive beam marks
1883
+ interface BeamableNote {
1884
+ note: NoteEvent;
1885
+ position: number; // position in eighths at start of this note
1886
+ }
1887
+
1888
+ // Apply auto-beam to a single voice's events
1889
+ const applyAutoBeamToVoice = (events: Event[], beamGroups: number[]): void => {
1890
+ // Compute group boundary positions in eighths
1891
+ const groupBoundaries: number[] = [];
1892
+ let boundary = 0;
1893
+ for (const size of beamGroups) {
1894
+ boundary += size;
1895
+ groupBoundaries.push(boundary);
1896
+ }
1897
+ const totalMeasureEighths = boundary;
1898
+
1899
+ // Collect beamable notes with their positions
1900
+ let position = 0;
1901
+ const beamableRuns: BeamableNote[][] = [];
1902
+ let currentRun: BeamableNote[] = [];
1903
+
1904
+ // Helper: find which group index a position belongs to
1905
+ const getGroupIndex = (pos: number): number => {
1906
+ for (let i = 0; i < groupBoundaries.length; i++) {
1907
+ if (pos < groupBoundaries[i]) return i;
1908
+ }
1909
+ return groupBoundaries.length - 1;
1910
+ };
1911
+
1912
+ // Helper: flush current run into beamableRuns
1913
+ const flushRun = () => {
1914
+ if (currentRun.length >= 2) {
1915
+ beamableRuns.push(currentRun);
1916
+ }
1917
+ currentRun = [];
1918
+ };
1919
+
1920
+ for (const event of events) {
1921
+ if (event.type === 'note') {
1922
+ const note = event as NoteEvent;
1923
+ if (note.grace) continue; // skip grace notes
1924
+
1925
+ const dur = durationInEighths(note.duration.division, note.duration.dots);
1926
+
1927
+ if (note.duration.division >= 8) {
1928
+ // Beamable note
1929
+ const groupIdx = getGroupIndex(position);
1930
+ const noteEndPos = position + dur;
1931
+ const endGroupIdx = getGroupIndex(Math.min(noteEndPos - 0.001, totalMeasureEighths - 0.001));
1932
+
1933
+ // Note must start and end within the same group
1934
+ if (groupIdx === endGroupIdx) {
1935
+ // Check if current run is in the same group
1936
+ if (currentRun.length > 0) {
1937
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1938
+ if (lastGroupIdx !== groupIdx) {
1939
+ flushRun();
1940
+ }
1941
+ }
1942
+ currentRun.push({ note, position });
1943
+ } else {
1944
+ // Note spans group boundary — break
1945
+ flushRun();
1946
+ }
1947
+ } else {
1948
+ // Non-beamable note (quarter or longer) — break
1949
+ flushRun();
1950
+ }
1951
+
1952
+ position += dur;
1953
+ } else if (event.type === 'rest') {
1954
+ const rest = event as RestEvent;
1955
+ const dur = durationInEighths(rest.duration.division, rest.duration.dots);
1956
+ // Rests break beam groups
1957
+ flushRun();
1958
+ position += dur;
1959
+ } else if (event.type === 'tuplet') {
1960
+ const tuplet = event as TupletEvent;
1961
+ const ratio = tuplet.ratio; // LilyPond ratio: num/den
1962
+
1963
+ // Check if all inner notes are beamable (division >= 8)
1964
+ const innerNotes: { note: NoteEvent; dur: number }[] = [];
1965
+ let allBeamable = true;
1966
+ let tupletDur = 0;
1967
+
1968
+ for (const e of tuplet.events) {
1969
+ if (e.type === 'note') {
1970
+ const note = e as NoteEvent;
1971
+ if (note.grace) continue;
1972
+ const dur = durationInEighths(note.duration.division, note.duration.dots, ratio);
1973
+ innerNotes.push({ note, dur });
1974
+ tupletDur += dur;
1975
+ if (note.duration.division < 8) {
1976
+ allBeamable = false;
1977
+ }
1978
+ } else if (e.type === 'rest') {
1979
+ allBeamable = false;
1980
+ const dur = durationInEighths(e.duration.division, e.duration.dots, ratio);
1981
+ tupletDur += dur;
1982
+ }
1983
+ }
1984
+
1985
+ if (allBeamable && innerNotes.length > 0) {
1986
+ const groupIdx = getGroupIndex(position);
1987
+ const endGroupIdx = getGroupIndex(Math.min(position + tupletDur - 0.001, totalMeasureEighths - 0.001));
1988
+
1989
+ if (groupIdx === endGroupIdx) {
1990
+ // Tuplet fits within one group — add inner notes to current run
1991
+ if (currentRun.length > 0) {
1992
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1993
+ if (lastGroupIdx !== groupIdx) {
1994
+ flushRun();
1995
+ }
1996
+ }
1997
+ let innerPos = position;
1998
+ for (const { note, dur } of innerNotes) {
1999
+ currentRun.push({ note, position: innerPos });
2000
+ innerPos += dur;
2001
+ }
2002
+ } else {
2003
+ flushRun();
2004
+ }
2005
+ } else {
2006
+ flushRun();
2007
+ }
2008
+
2009
+ position += tupletDur;
2010
+ } else if (event.type === 'context' || event.type === 'pitchReset' || event.type === 'barline' || event.type === 'harmony' || event.type === 'markup') {
2011
+ // Non-musical events: don't advance position, don't break beams
2012
+ continue;
2013
+ } else if (event.type === 'tremolo') {
2014
+ // Tremolo breaks beams
2015
+ const trem = event as TremoloEvent;
2016
+ // Total duration = count * 2 * (1/division) in whole notes
2017
+ // In eighths: count * 2 * (8/division)
2018
+ const dur = trem.count * 2 * (8 / trem.division);
2019
+ flushRun();
2020
+ position += dur;
2021
+ }
2022
+ }
2023
+
2024
+ // Flush any remaining run
2025
+ flushRun();
2026
+
2027
+ // Apply beam marks to collected runs
2028
+ for (const run of beamableRuns) {
2029
+ const first = run[0].note;
2030
+ const last = run[run.length - 1].note;
2031
+
2032
+ if (!first.marks) first.marks = [];
2033
+ first.marks.push({ markType: 'beam', start: true } as Beam);
2034
+
2035
+ if (!last.marks) last.marks = [];
2036
+ last.marks.push({ markType: 'beam', start: false } as Beam);
2037
+ }
2038
+ };
2039
+
2040
+
1767
2041
  // Main encode function
1768
2042
  const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1769
2043
  const indent = options.indent || " ";
@@ -1797,6 +2071,12 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1797
2071
 
1798
2072
  const keySig = KEY_SIGS[currentKey] || "0";
1799
2073
 
2074
+ // Apply auto-beam if needed (before encoding so beam marks are picked up by encodeLayer)
2075
+ const shouldAutoBeam = resolveAutoBeam(doc);
2076
+ if (shouldAutoBeam) {
2077
+ applyAutoBeam(doc);
2078
+ }
2079
+
1800
2080
  // Build MEI document
1801
2081
  const xmlDecl = options.xmlDeclaration !== false
1802
2082
  ? '<?xml version="1.0" encoding="UTF-8"?>\n'