@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.
- 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 +1813 -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 +702 -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 +195 -190
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +10 -3
- package/source/lilylet/lilypondDecoder.ts +91 -41
- package/source/lilylet/meiEncoder.ts +284 -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 +75 -21
- 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
|
|
@@ -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 '{'
|
|
512
|
-
| CMD_REPEAT TREMOLO NUMBER '{'
|
|
513
|
-
| CMD_REPEAT TREMOLO NUMBER '{'
|
|
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
|
-
|
|
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
|
|
|
@@ -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'
|