@k-l-lambda/lilylet 0.1.49 → 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 +91 -41
  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
@@ -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'
@@ -66,6 +66,7 @@ import {
66
66
  convertBarlineStyle,
67
67
  convertHarmonyToText,
68
68
  createFraction,
69
+ TYPE_TO_DIVISION,
69
70
  } from './musicXmlUtils';
70
71
 
71
72
  // ============ Spanner Tracker ============
@@ -163,12 +164,9 @@ class TupletTracker {
163
164
  // Add to all active tuplets (in case of nested tuplets)
164
165
  for (const [, tuplet] of this.activeTuplets) {
165
166
  // Set ratio from first event's duration.tuplet
167
+ // convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
166
168
  if (!tuplet.ratio && event.duration.tuplet) {
167
- // In Lilylet, ratio is denominator/numerator (e.g., 2/3 for triplet)
168
- tuplet.ratio = {
169
- numerator: event.duration.tuplet.denominator,
170
- denominator: event.duration.tuplet.numerator,
171
- };
169
+ tuplet.ratio = { ...event.duration.tuplet };
172
170
  }
173
171
  // Store event without tuplet info in duration (it's handled at TupletEvent level)
174
172
  const cleanEvent = { ...event, duration: { ...event.duration } };
@@ -237,6 +235,7 @@ class VoiceTracker {
237
235
  private currentPosition: number = 0;
238
236
  private divisions: number = 1;
239
237
  private staves: number = 1;
238
+ private currentStaff: Map<number, number> = new Map();
240
239
 
241
240
  setDivisions(div: number): void {
242
241
  this.divisions = div;
@@ -260,6 +259,7 @@ class VoiceTracker {
260
259
  events: [],
261
260
  staff,
262
261
  });
262
+ this.currentStaff.set(voiceNum, staff);
263
263
  }
264
264
  const voice = this.voices.get(voiceNum)!;
265
265
  // Update staff if specified
@@ -271,6 +271,11 @@ class VoiceTracker {
271
271
 
272
272
  addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
273
273
  const voice = this.getOrCreateVoice(voiceNum, staff);
274
+ const prevStaff = this.currentStaff.get(voiceNum) || 1;
275
+ if (staff > 0 && staff !== prevStaff) {
276
+ voice.events.push({ type: 'context', staff } as ContextChange);
277
+ this.currentStaff.set(voiceNum, staff);
278
+ }
274
279
  voice.events.push(event);
275
280
  voice.lastEvent = event;
276
281
  this.currentPosition += duration;
@@ -306,6 +311,7 @@ class VoiceTracker {
306
311
  reset(): void {
307
312
  this.voices.clear();
308
313
  this.currentPosition = 0;
314
+ this.currentStaff.clear();
309
315
  }
310
316
  }
311
317
 
@@ -825,20 +831,6 @@ const notationsToMarks = (
825
831
  return marks;
826
832
  };
827
833
 
828
- // MusicXML beat-unit to division mapping
829
- const BEAT_UNIT_TO_DIVISION: Record<string, number> = {
830
- 'maxima': 0.125,
831
- 'long': 0.25,
832
- 'breve': 0.5,
833
- 'whole': 1,
834
- 'half': 2,
835
- 'quarter': 4,
836
- 'eighth': 8,
837
- '16th': 16,
838
- '32nd': 32,
839
- '64th': 64,
840
- };
841
-
842
834
  // Common tempo words that should be converted to \tempo
843
835
  const TEMPO_WORDS = new Set([
844
836
  // Very slow
@@ -878,7 +870,7 @@ const directionToContextChange = (
878
870
  // Metronome → Tempo (may combine with words)
879
871
  if (direction.metronome) {
880
872
  const { beatUnit, beatUnitDot, perMinute } = direction.metronome;
881
- const division = BEAT_UNIT_TO_DIVISION[beatUnit] || 4;
873
+ const division = TYPE_TO_DIVISION[beatUnit] || 4;
882
874
 
883
875
  // Check if there's accompanying tempo text
884
876
  let tempoText: string | undefined;
@@ -1078,6 +1070,10 @@ const convertMeasure = (
1078
1070
  const staffNum = note.staff || 1;
1079
1071
  currentVoice = voiceNum;
1080
1072
 
1073
+ // Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
1074
+ // where notes go to tupletTracker but voice must be initialized for staff detection)
1075
+ voiceTracker.getOrCreateVoice(voiceNum, staffNum);
1076
+
1081
1077
  // Check for tuplet start BEFORE processing the note
1082
1078
  const tupletNotation = note.notations?.tuplet;
1083
1079
  if (tupletNotation?.type === 'start') {
@@ -1283,6 +1279,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1283
1279
  let lastKey: KeySignature | undefined;
1284
1280
  let lastTimeSig: Fraction | undefined;
1285
1281
  let isFirstMeasure = true;
1282
+ let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
1286
1283
  const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
1287
1284
 
1288
1285
  const measureEls = getDirectChildren(partEl, 'measure');
@@ -1351,7 +1348,9 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1351
1348
 
1352
1349
  // If no voices found, create an empty one
1353
1350
  if (voices.length === 0) {
1354
- voices.push({ staff: 1, events: [] });
1351
+ voices.push({ staff: lastVoiceStaff, events: [] });
1352
+ } else {
1353
+ lastVoiceStaff = voices[0].staff || 1;
1355
1354
  }
1356
1355
 
1357
1356
  const measure: Measure = {
@@ -1395,19 +1394,67 @@ export const decode = (xmlString: string): LilyletDoc => {
1395
1394
  // Parse metadata
1396
1395
  const metadata = parseMetadata(doc);
1397
1396
 
1397
+ // Parse <part-list> to get part names
1398
+ const partNames: Map<string, string> = new Map();
1399
+ const partListEl = doc.getElementsByTagName('part-list')[0];
1400
+ if (partListEl) {
1401
+ const scorePartEls = getElements(partListEl, 'score-part');
1402
+ for (const sp of scorePartEls) {
1403
+ const id = getAttribute(sp, 'id');
1404
+ const name = getElementText(sp, 'part-name');
1405
+ if (id && name) {
1406
+ partNames.set(id, name);
1407
+ }
1408
+ }
1409
+ }
1410
+
1398
1411
  // Get parts
1399
- const partEls = Array.from(doc.getElementsByTagName('part'));
1412
+ const partEls = getDirectChildren(root, 'part');
1400
1413
  if (partEls.length === 0) {
1401
1414
  throw new Error('No parts found in MusicXML');
1402
1415
  }
1403
1416
 
1404
- // For now, convert only the first part
1405
- // TODO: Handle multiple parts
1406
- const firstPart = partEls[0];
1407
- const { measures } = convertPart(firstPart);
1417
+ // Convert all parts
1418
+ const allPartResults: { measures: Measure[]; name?: string; partId?: string }[] = [];
1419
+ for (const partEl of partEls) {
1420
+ const partId = getAttribute(partEl, 'id') || undefined;
1421
+ const { measures } = convertPart(partEl);
1422
+ const name = partId ? partNames.get(partId) : undefined;
1423
+ allPartResults.push({ measures, name, partId });
1424
+ }
1425
+
1426
+ // Merge parts: combine into multi-part measures
1427
+ const numMeasures = Math.max(...allPartResults.map(p => p.measures.length));
1428
+ const mergedMeasures: Measure[] = [];
1429
+
1430
+ for (let mi = 0; mi < numMeasures; mi++) {
1431
+ const parts: Part[] = [];
1432
+
1433
+ for (const partResult of allPartResults) {
1434
+ const sourceMeasure = partResult.measures[mi];
1435
+ if (sourceMeasure && sourceMeasure.parts.length > 0) {
1436
+ const part = sourceMeasure.parts[0];
1437
+ if (partResult.name) {
1438
+ part.name = partResult.name;
1439
+ }
1440
+ parts.push(part);
1441
+ } else {
1442
+ // Empty part placeholder
1443
+ parts.push({ voices: [{ staff: 1, events: [] }] });
1444
+ }
1445
+ }
1446
+
1447
+ // Use key/timeSig from the first part's measure (they should be consistent)
1448
+ const firstPartMeasure = allPartResults[0].measures[mi];
1449
+ const measure: Measure = { parts };
1450
+ if (firstPartMeasure?.key) measure.key = firstPartMeasure.key;
1451
+ if (firstPartMeasure?.timeSig) measure.timeSig = firstPartMeasure.timeSig;
1452
+
1453
+ mergedMeasures.push(measure);
1454
+ }
1408
1455
 
1409
1456
  const result: LilyletDoc = {
1410
- measures,
1457
+ measures: mergedMeasures,
1411
1458
  };
1412
1459
 
1413
1460
  if (Object.keys(metadata).length > 0) {