@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.
- package/lib/abc/abc.d.ts +102 -0
- package/lib/abc/abc.js +25 -0
- package/lib/abc/grammar.jison.js +1203 -0
- package/lib/abc/parser.d.ts +3 -0
- package/lib/abc/parser.js +6 -0
- package/lib/abcDecoder.d.ts +1 -0
- package/lib/abcDecoder.js +1 -0
- package/lib/grammar.jison.js +1 -1303
- package/lib/index.d.ts +1 -8
- package/lib/index.js +1 -10
- package/lib/lilylet/abcDecoder.d.ts +25 -0
- package/lib/lilylet/abcDecoder.js +1007 -0
- package/lib/lilylet/grammar.jison.js +1308 -0
- package/lib/lilylet/index.d.ts +10 -0
- package/lib/lilylet/index.js +10 -0
- package/lib/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/lilylet/lilypondDecoder.js +1053 -0
- package/lib/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/lilylet/lilypondEncoder.js +759 -0
- package/lib/lilylet/meiEncoder.d.ts +8 -0
- package/lib/lilylet/meiEncoder.js +1808 -0
- package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/lilylet/musicXmlEncoder.js +701 -0
- package/lib/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/lilylet/musicXmlTypes.js +7 -0
- package/lib/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/lilylet/musicXmlUtils.js +469 -0
- package/lib/lilylet/parser.d.ts +3 -0
- package/lib/lilylet/parser.js +151 -0
- package/lib/lilylet/serializer.d.ts +11 -0
- package/lib/lilylet/serializer.js +653 -0
- package/lib/lilylet/types.d.ts +245 -0
- package/lib/lilylet/types.js +99 -0
- package/lib/lilypondDecoder.d.ts +1 -29
- package/lib/lilypondDecoder.js +1 -1006
- package/lib/lilypondEncoder.d.ts +1 -34
- package/lib/lilypondEncoder.js +1 -759
- package/lib/meiEncoder.d.ts +1 -8
- package/lib/meiEncoder.js +1 -1545
- package/lib/musicXmlDecoder.d.ts +1 -20
- package/lib/musicXmlDecoder.js +1 -1151
- package/lib/musicXmlEncoder.d.ts +1 -15
- package/lib/musicXmlEncoder.js +1 -666
- package/lib/musicXmlTypes.d.ts +1 -199
- package/lib/musicXmlTypes.js +1 -7
- package/lib/musicXmlUtils.d.ts +1 -81
- package/lib/musicXmlUtils.js +1 -435
- package/lib/parser.d.ts +1 -3
- package/lib/parser.js +1 -151
- package/lib/serializer.d.ts +1 -11
- package/lib/serializer.js +1 -650
- package/lib/types.d.ts +1 -244
- package/lib/types.js +1 -99
- package/package.json +2 -1
- package/source/abc/abc.jison +692 -0
- package/source/abc/abc.ts +176 -0
- package/source/abc/grammar.jison.js +1203 -0
- package/source/abc/parser.ts +12 -0
- package/source/lilylet/abcDecoder.ts +1121 -0
- package/source/lilylet/grammar.jison.js +170 -165
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/lilypondDecoder.ts +91 -41
- package/source/lilylet/meiEncoder.ts +280 -0
- package/source/lilylet/musicXmlDecoder.ts +74 -27
- package/source/lilylet/musicXmlEncoder.ts +201 -146
- package/source/lilylet/musicXmlUtils.ts +46 -4
- package/source/lilylet/serializer.ts +3 -0
- package/source/lilylet/types.ts +1 -0
package/source/lilylet/index.ts
CHANGED
|
@@ -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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
677
|
+
// Add tie
|
|
678
|
+
if (term.isTying) {
|
|
679
|
+
marks.push({ markType: 'tie', start: true });
|
|
680
|
+
}
|
|
672
681
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
682
|
+
const noteEvent: NoteEvent = {
|
|
683
|
+
type: 'note',
|
|
684
|
+
pitches,
|
|
685
|
+
duration: convertDuration(term.durationValue),
|
|
686
|
+
grace: context.inGrace || undefined,
|
|
687
|
+
};
|
|
679
688
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
689
|
+
if (marks.length > 0) {
|
|
690
|
+
noteEvent.marks = marks;
|
|
691
|
+
}
|
|
683
692
|
|
|
684
|
-
|
|
693
|
+
voice.events.push(noteEvent);
|
|
685
694
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
-
//
|
|
1405
|
-
|
|
1406
|
-
const
|
|
1407
|
-
|
|
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) {
|