@k-l-lambda/lilylet 0.1.63 → 0.1.65
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/lilylet/grammar.jison.js +1 -10
- package/lib/lilylet/meiEncoder.js +58 -40
- package/lib/source/abc/abc.d.ts +102 -0
- package/lib/source/abc/abc.js +25 -0
- package/lib/source/abc/parser.d.ts +3 -0
- package/lib/source/abc/parser.js +6 -0
- package/lib/source/lilylet/abcDecoder.d.ts +25 -0
- package/lib/source/lilylet/abcDecoder.js +1035 -0
- package/lib/source/lilylet/index.d.ts +10 -0
- package/lib/source/lilylet/index.js +10 -0
- package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/source/lilylet/lilypondDecoder.js +1223 -0
- package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/source/lilylet/lilypondEncoder.js +893 -0
- package/lib/source/lilylet/meiEncoder.d.ts +8 -0
- package/lib/source/lilylet/meiEncoder.js +1985 -0
- package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/source/lilylet/musicXmlEncoder.js +701 -0
- package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/source/lilylet/musicXmlTypes.js +7 -0
- package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/source/lilylet/musicXmlUtils.js +469 -0
- package/lib/source/lilylet/parser.d.ts +14 -0
- package/lib/source/lilylet/parser.js +161 -0
- package/lib/source/lilylet/serializer.d.ts +11 -0
- package/lib/source/lilylet/serializer.js +791 -0
- package/lib/source/lilylet/types.d.ts +253 -0
- package/lib/source/lilylet/types.js +100 -0
- package/lib/tests/abc-abcjs-parse.d.ts +8 -0
- package/lib/tests/abc-abcjs-parse.js +90 -0
- package/lib/tests/abc-abcjs-svg.d.ts +1 -0
- package/lib/tests/abc-abcjs-svg.js +143 -0
- package/lib/tests/abc-decoder.d.ts +1 -0
- package/lib/tests/abc-decoder.js +67 -0
- package/lib/tests/abc-mei-compare.d.ts +1 -0
- package/lib/tests/abc-mei-compare.js +525 -0
- package/lib/tests/auto-beam.d.ts +9 -0
- package/lib/tests/auto-beam.js +151 -0
- package/lib/tests/computeMeiHashes.d.ts +1 -0
- package/lib/tests/computeMeiHashes.js +87 -0
- package/lib/tests/encoder-mutation.d.ts +9 -0
- package/lib/tests/encoder-mutation.js +110 -0
- package/lib/tests/gpt-review-issues.d.ts +5 -0
- package/lib/tests/gpt-review-issues.js +255 -0
- package/lib/tests/json-to-lyl.d.ts +1 -0
- package/lib/tests/json-to-lyl.js +18 -0
- package/lib/tests/lilypond-roundtrip.d.ts +7 -0
- package/lib/tests/lilypond-roundtrip.js +558 -0
- package/lib/tests/lilypondDecoder.d.ts +6 -0
- package/lib/tests/lilypondDecoder.js +95 -0
- package/lib/tests/ly-to-lyl.d.ts +1 -0
- package/lib/tests/ly-to-lyl.js +12 -0
- package/lib/tests/mei.d.ts +1 -0
- package/lib/tests/mei.js +278 -0
- package/lib/tests/musicxml-decoder.d.ts +4 -0
- package/lib/tests/musicxml-decoder.js +61 -0
- package/lib/tests/musicxml-detail.d.ts +4 -0
- package/lib/tests/musicxml-detail.js +85 -0
- package/lib/tests/musicxml-fprod.d.ts +9 -0
- package/lib/tests/musicxml-fprod.js +153 -0
- package/lib/tests/musicxml-roundtrip.d.ts +7 -0
- package/lib/tests/musicxml-roundtrip.js +296 -0
- package/lib/tests/musicxml-to-mei.d.ts +6 -0
- package/lib/tests/musicxml-to-mei.js +115 -0
- package/lib/tests/parser.d.ts +1 -0
- package/lib/tests/parser.js +17 -0
- package/lib/tests/render-k283.d.ts +1 -0
- package/lib/tests/render-k283.js +33 -0
- package/lib/tests/render-lyl.d.ts +1 -0
- package/lib/tests/render-lyl.js +35 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
- package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
- package/lib/tests/unit/gptReviewIssues.test.js +240 -0
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
- package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
- package/lib/tests/unit/partialWarning.test.d.ts +4 -0
- package/lib/tests/unit/partialWarning.test.js +65 -0
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
- package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
- package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
- package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +1 -10
- package/source/lilylet/lilylet.jison +1 -10
- package/source/lilylet/meiEncoder.ts +65 -40
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LilyPond to Lilylet Decoder
|
|
3
|
+
*
|
|
4
|
+
* Converts LilyPond notation files to Lilylet document format using the lotus parser.
|
|
5
|
+
* This module is browser-compatible - it uses pre-compiled parser from lotus.
|
|
6
|
+
*/
|
|
7
|
+
// Import directly from the compiled lib directory to avoid ESM issues
|
|
8
|
+
import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
|
|
9
|
+
// Import pre-compiled LilyPond parser (browser-compatible)
|
|
10
|
+
// @ts-ignore - CommonJS module
|
|
11
|
+
import * as lilypondParser from "@k-l-lambda/lotus/lib.browser/lib/lilyParser.js";
|
|
12
|
+
import { Clef, StemDirection, Accidental, Phonet, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
|
|
13
|
+
// Phonet names mapping
|
|
14
|
+
const PHONET_NAMES = {
|
|
15
|
+
0: Phonet.c,
|
|
16
|
+
1: Phonet.d,
|
|
17
|
+
2: Phonet.e,
|
|
18
|
+
3: Phonet.f,
|
|
19
|
+
4: Phonet.g,
|
|
20
|
+
5: Phonet.a,
|
|
21
|
+
6: Phonet.b,
|
|
22
|
+
};
|
|
23
|
+
// Alter value to accidental
|
|
24
|
+
const ALTER_TO_ACCIDENTAL = {
|
|
25
|
+
[-2]: Accidental.doubleFlat,
|
|
26
|
+
[-1]: Accidental.flat,
|
|
27
|
+
[0]: Accidental.natural,
|
|
28
|
+
[1]: Accidental.sharp,
|
|
29
|
+
[2]: Accidental.doubleSharp,
|
|
30
|
+
};
|
|
31
|
+
// LilyPond clef names to Lilylet clef
|
|
32
|
+
const LILYPOND_CLEF_MAP = {
|
|
33
|
+
treble: Clef.treble,
|
|
34
|
+
G: Clef.treble,
|
|
35
|
+
bass: Clef.bass,
|
|
36
|
+
F: Clef.bass,
|
|
37
|
+
alto: Clef.alto,
|
|
38
|
+
C: Clef.alto,
|
|
39
|
+
};
|
|
40
|
+
// Key signature fifths to pitch/accidental mapping
|
|
41
|
+
const KEY_FIFTHS_MAP = {
|
|
42
|
+
[-7]: { pitch: Phonet.c, accidental: Accidental.flat, mode: 'major' },
|
|
43
|
+
[-6]: { pitch: Phonet.g, accidental: Accidental.flat, mode: 'major' },
|
|
44
|
+
[-5]: { pitch: Phonet.d, accidental: Accidental.flat, mode: 'major' },
|
|
45
|
+
[-4]: { pitch: Phonet.a, accidental: Accidental.flat, mode: 'major' },
|
|
46
|
+
[-3]: { pitch: Phonet.e, accidental: Accidental.flat, mode: 'major' },
|
|
47
|
+
[-2]: { pitch: Phonet.b, accidental: Accidental.flat, mode: 'major' },
|
|
48
|
+
[-1]: { pitch: Phonet.f, mode: 'major' },
|
|
49
|
+
[0]: { pitch: Phonet.c, mode: 'major' },
|
|
50
|
+
[1]: { pitch: Phonet.g, mode: 'major' },
|
|
51
|
+
[2]: { pitch: Phonet.d, mode: 'major' },
|
|
52
|
+
[3]: { pitch: Phonet.a, mode: 'major' },
|
|
53
|
+
[4]: { pitch: Phonet.e, mode: 'major' },
|
|
54
|
+
[5]: { pitch: Phonet.b, mode: 'major' },
|
|
55
|
+
[6]: { pitch: Phonet.f, accidental: Accidental.sharp, mode: 'major' },
|
|
56
|
+
[7]: { pitch: Phonet.c, accidental: Accidental.sharp, mode: 'major' },
|
|
57
|
+
};
|
|
58
|
+
// Articulation mapping from LilyPond
|
|
59
|
+
const ARTICULATION_MAP = {
|
|
60
|
+
staccato: ArticulationType.staccato,
|
|
61
|
+
staccatissimo: ArticulationType.staccatissimo,
|
|
62
|
+
tenuto: ArticulationType.tenuto,
|
|
63
|
+
marcato: ArticulationType.marcato,
|
|
64
|
+
accent: ArticulationType.accent,
|
|
65
|
+
portato: ArticulationType.portato,
|
|
66
|
+
};
|
|
67
|
+
// Ornament mapping from LilyPond
|
|
68
|
+
const ORNAMENT_MAP = {
|
|
69
|
+
trill: OrnamentType.trill,
|
|
70
|
+
turn: OrnamentType.turn,
|
|
71
|
+
mordent: OrnamentType.mordent,
|
|
72
|
+
prall: OrnamentType.prall,
|
|
73
|
+
fermata: OrnamentType.fermata,
|
|
74
|
+
shortfermata: OrnamentType.shortFermata,
|
|
75
|
+
arpeggio: OrnamentType.arpeggio,
|
|
76
|
+
};
|
|
77
|
+
// Dynamic regex and mapping
|
|
78
|
+
const DYNAMIC_REGEX = /^[fpmrsz]+$/;
|
|
79
|
+
const DYNAMIC_MAP = {
|
|
80
|
+
ppp: DynamicType.ppp,
|
|
81
|
+
pp: DynamicType.pp,
|
|
82
|
+
p: DynamicType.p,
|
|
83
|
+
mp: DynamicType.mp,
|
|
84
|
+
mf: DynamicType.mf,
|
|
85
|
+
f: DynamicType.f,
|
|
86
|
+
ff: DynamicType.ff,
|
|
87
|
+
fff: DynamicType.fff,
|
|
88
|
+
sfz: DynamicType.sfz,
|
|
89
|
+
rfz: DynamicType.rfz,
|
|
90
|
+
};
|
|
91
|
+
// Common tempo text words (from fprod corpus analysis)
|
|
92
|
+
// Single words, lowercase for case-insensitive matching
|
|
93
|
+
const TEMPO_WORDS = new Set([
|
|
94
|
+
// Basic tempo markings (Italian) - very slow to very fast
|
|
95
|
+
'grave', 'largo', 'larghetto', 'lento', 'adagio', 'adagietto',
|
|
96
|
+
'andante', 'andantino', 'moderato', 'allegretto', 'allegro',
|
|
97
|
+
'vivace', 'presto', 'prestissimo',
|
|
98
|
+
// Tempo modifiers
|
|
99
|
+
'molto', 'poco', 'più', 'meno', 'assai', 'con', 'moto', 'brio',
|
|
100
|
+
'ma', 'non', 'troppo', 'cantabile', 'sostenuto', 'espressivo',
|
|
101
|
+
'grazioso', 'maestoso', 'agitato', 'animato', 'tranquillo',
|
|
102
|
+
// Tempo changes
|
|
103
|
+
'tempo', 'primo',
|
|
104
|
+
'rit', 'ritard', 'ritardando', 'riten', 'ritenuto',
|
|
105
|
+
'rall', 'rallentando',
|
|
106
|
+
'accel', 'accelerando',
|
|
107
|
+
'allarg', 'allargando',
|
|
108
|
+
'calando', 'morendo', 'smorzando', 'smorz',
|
|
109
|
+
'rubato',
|
|
110
|
+
]);
|
|
111
|
+
// Check if text contains any tempo-related word (case-insensitive)
|
|
112
|
+
const containsTempoWord = (text) => {
|
|
113
|
+
// Remove punctuation and split into words
|
|
114
|
+
const words = text.toLowerCase().replace(/[.,!?]/g, '').split(/\s+/);
|
|
115
|
+
return words.some(word => TEMPO_WORDS.has(word));
|
|
116
|
+
};
|
|
117
|
+
// Convert lotus Tempo to Lilylet Tempo
|
|
118
|
+
const convertTempo = (lotusTempo) => {
|
|
119
|
+
if (!lotusTempo)
|
|
120
|
+
return undefined;
|
|
121
|
+
const tempo = {};
|
|
122
|
+
// Text (e.g., "Allegro")
|
|
123
|
+
if (lotusTempo.text) {
|
|
124
|
+
tempo.text = typeof lotusTempo.text === 'string'
|
|
125
|
+
? lotusTempo.text
|
|
126
|
+
: extractTextFromObject(lotusTempo.text);
|
|
127
|
+
}
|
|
128
|
+
// Metronome mark (e.g., ♩ = 120)
|
|
129
|
+
if (lotusTempo.beatsPerMinute !== undefined && Number.isFinite(lotusTempo.beatsPerMinute)) {
|
|
130
|
+
tempo.bpm = lotusTempo.beatsPerMinute;
|
|
131
|
+
// Beat unit (note value)
|
|
132
|
+
if (lotusTempo.unit) {
|
|
133
|
+
tempo.beat = convertDuration(lotusTempo.unit);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Return undefined if no meaningful tempo data
|
|
137
|
+
if (!tempo.text && !tempo.bpm) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return tempo;
|
|
141
|
+
};
|
|
142
|
+
// Convert LilyPond pitch to Lilylet pitch
|
|
143
|
+
const convertPitch = (phonetStep, alterValue, octave) => {
|
|
144
|
+
const phonet = PHONET_NAMES[phonetStep % 7];
|
|
145
|
+
const accidental = alterValue !== 0 ? ALTER_TO_ACCIDENTAL[alterValue] : undefined;
|
|
146
|
+
// Lotus parser absolute octave: 0 = C3, 1 = C4, 2 = C5
|
|
147
|
+
// Lilylet octave: 0 = C4, 1 = C5, -1 = C3
|
|
148
|
+
// Conversion: lilyletOctave = lotusAbsoluteOctave - 1
|
|
149
|
+
const lilyletOctave = octave - 1;
|
|
150
|
+
return {
|
|
151
|
+
phonet,
|
|
152
|
+
accidental,
|
|
153
|
+
octave: lilyletOctave,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
// Convert LilyPond duration to Lilylet duration
|
|
157
|
+
const convertDuration = (duration) => {
|
|
158
|
+
return {
|
|
159
|
+
division: Math.pow(2, duration.division),
|
|
160
|
+
dots: duration.dots || 0,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
// Parse raw pitch string (e.g., "c'", "fis''", "bes,") to Pitch
|
|
164
|
+
const parseRawPitch = (pitchStr) => {
|
|
165
|
+
if (!pitchStr)
|
|
166
|
+
return undefined;
|
|
167
|
+
// Match: base note (a-g), optional accidentals (is/es/isis/eses), optional octave marks ('/, or ,)
|
|
168
|
+
const match = pitchStr.match(/^([a-g])(isis|eses|is|es)?([',]*)$/);
|
|
169
|
+
if (!match)
|
|
170
|
+
return undefined;
|
|
171
|
+
const [, note, accidental, octaveMarks] = match;
|
|
172
|
+
// Map note to phonet
|
|
173
|
+
const phonetMap = {
|
|
174
|
+
c: Phonet.c, d: Phonet.d, e: Phonet.e, f: Phonet.f,
|
|
175
|
+
g: Phonet.g, a: Phonet.a, b: Phonet.b,
|
|
176
|
+
};
|
|
177
|
+
const phonet = phonetMap[note];
|
|
178
|
+
if (!phonet)
|
|
179
|
+
return undefined;
|
|
180
|
+
// Map accidental
|
|
181
|
+
const accidentalMap = {
|
|
182
|
+
is: Accidental.sharp,
|
|
183
|
+
es: Accidental.flat,
|
|
184
|
+
isis: Accidental.doubleSharp,
|
|
185
|
+
eses: Accidental.doubleFlat,
|
|
186
|
+
};
|
|
187
|
+
const acc = accidental ? accidentalMap[accidental] : undefined;
|
|
188
|
+
// Calculate octave from marks (default octave 0 = C4)
|
|
189
|
+
let octave = 0;
|
|
190
|
+
for (const mark of octaveMarks || '') {
|
|
191
|
+
if (mark === "'")
|
|
192
|
+
octave++;
|
|
193
|
+
else if (mark === ",")
|
|
194
|
+
octave--;
|
|
195
|
+
}
|
|
196
|
+
return { phonet, accidental: acc, octave };
|
|
197
|
+
};
|
|
198
|
+
// Parse raw duration object from tuplet body
|
|
199
|
+
const parseRawDuration = (duration) => {
|
|
200
|
+
if (!duration)
|
|
201
|
+
return undefined;
|
|
202
|
+
const number = parseInt(duration.number, 10);
|
|
203
|
+
if (isNaN(number))
|
|
204
|
+
return undefined;
|
|
205
|
+
return {
|
|
206
|
+
division: number,
|
|
207
|
+
dots: duration.dots || 0,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
// Convert raw Chord from tuplet body to NoteEvent
|
|
211
|
+
const convertRawChord = (chord, defaultDuration) => {
|
|
212
|
+
if (!chord || chord.proto !== 'Chord')
|
|
213
|
+
return undefined;
|
|
214
|
+
const pitches = [];
|
|
215
|
+
for (const pitchElem of chord.pitches || []) {
|
|
216
|
+
const pitch = parseRawPitch(pitchElem.pitch);
|
|
217
|
+
if (pitch)
|
|
218
|
+
pitches.push(pitch);
|
|
219
|
+
}
|
|
220
|
+
if (pitches.length === 0)
|
|
221
|
+
return undefined;
|
|
222
|
+
const duration = parseRawDuration(chord.duration) || defaultDuration;
|
|
223
|
+
if (!duration)
|
|
224
|
+
return undefined;
|
|
225
|
+
return {
|
|
226
|
+
type: 'note',
|
|
227
|
+
pitches,
|
|
228
|
+
duration,
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
// Parse pitch name with accidental (e.g., "cf" -> { pitch: Phonet.c, accidental: Accidental.flat })
|
|
232
|
+
const parsePitchName = (name) => {
|
|
233
|
+
if (!name || name.length === 0)
|
|
234
|
+
return undefined;
|
|
235
|
+
const phonetChar = name[0].toLowerCase();
|
|
236
|
+
const phonet = {
|
|
237
|
+
'c': Phonet.c, 'd': Phonet.d, 'e': Phonet.e, 'f': Phonet.f,
|
|
238
|
+
'g': Phonet.g, 'a': Phonet.a, 'b': Phonet.b
|
|
239
|
+
}[phonetChar];
|
|
240
|
+
if (!phonet)
|
|
241
|
+
return undefined;
|
|
242
|
+
const accidentalPart = name.slice(1);
|
|
243
|
+
let accidental;
|
|
244
|
+
if (accidentalPart === 's' || accidentalPart === 'is') {
|
|
245
|
+
accidental = Accidental.sharp;
|
|
246
|
+
}
|
|
247
|
+
else if (accidentalPart === 'ss' || accidentalPart === 'isis') {
|
|
248
|
+
accidental = Accidental.doubleSharp;
|
|
249
|
+
}
|
|
250
|
+
else if (accidentalPart === 'f' || accidentalPart === 'es') {
|
|
251
|
+
accidental = Accidental.flat;
|
|
252
|
+
}
|
|
253
|
+
else if (accidentalPart === 'ff' || accidentalPart === 'eses') {
|
|
254
|
+
accidental = Accidental.doubleFlat;
|
|
255
|
+
}
|
|
256
|
+
return { pitch: phonet, accidental };
|
|
257
|
+
};
|
|
258
|
+
// Convert key from context to KeySignature
|
|
259
|
+
const convertKeySignature = (keyContext) => {
|
|
260
|
+
const args = keyContext?.args;
|
|
261
|
+
// Always parse from args to get correct pitch and mode
|
|
262
|
+
if (Array.isArray(args) && args.length >= 2) {
|
|
263
|
+
const pitchStr = args[0];
|
|
264
|
+
const modeStr = args[1];
|
|
265
|
+
const pitchInfo = parsePitchName(pitchStr);
|
|
266
|
+
if (pitchInfo) {
|
|
267
|
+
const mode = modeStr?.includes('minor') ? 'minor' : 'major';
|
|
268
|
+
return {
|
|
269
|
+
pitch: pitchInfo.pitch,
|
|
270
|
+
accidental: pitchInfo.accidental,
|
|
271
|
+
mode,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Fallback to fifths lookup for compatibility (major keys only)
|
|
276
|
+
const fifths = keyContext?.key;
|
|
277
|
+
if (fifths !== undefined && KEY_FIFTHS_MAP[fifths]) {
|
|
278
|
+
const mapping = KEY_FIFTHS_MAP[fifths];
|
|
279
|
+
return {
|
|
280
|
+
pitch: mapping.pitch,
|
|
281
|
+
accidental: mapping.accidental,
|
|
282
|
+
mode: mapping.mode,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
};
|
|
287
|
+
const parsePostEvents = (postEvents) => {
|
|
288
|
+
const marks = [];
|
|
289
|
+
let harmonyText;
|
|
290
|
+
if (!postEvents)
|
|
291
|
+
return { marks };
|
|
292
|
+
for (const event of postEvents) {
|
|
293
|
+
// String events
|
|
294
|
+
if (typeof event === 'string') {
|
|
295
|
+
if (event === '~') {
|
|
296
|
+
marks.push({ markType: 'tie', start: true });
|
|
297
|
+
}
|
|
298
|
+
else if (event === '(') {
|
|
299
|
+
marks.push({ markType: 'slur', start: true });
|
|
300
|
+
}
|
|
301
|
+
else if (event === ')') {
|
|
302
|
+
marks.push({ markType: 'slur', start: false });
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// PostEvent objects
|
|
307
|
+
if (event && typeof event === 'object') {
|
|
308
|
+
const arg = event.arg;
|
|
309
|
+
// String articulation/ornament
|
|
310
|
+
if (typeof arg === 'string') {
|
|
311
|
+
const cleanArg = arg.replace(/^-/, '');
|
|
312
|
+
if (ARTICULATION_MAP[cleanArg]) {
|
|
313
|
+
marks.push({ markType: 'articulation', type: ARTICULATION_MAP[cleanArg] });
|
|
314
|
+
}
|
|
315
|
+
else if (ORNAMENT_MAP[cleanArg]) {
|
|
316
|
+
marks.push({ markType: 'ornament', type: ORNAMENT_MAP[cleanArg] });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Fingering (number 1-5)
|
|
320
|
+
if (typeof arg === 'number' && arg >= 1 && arg <= 5) {
|
|
321
|
+
marks.push({ markType: 'fingering', finger: arg });
|
|
322
|
+
}
|
|
323
|
+
// Command (dynamics, hairpins, etc.)
|
|
324
|
+
if (arg && typeof arg === 'object' && 'cmd' in arg) {
|
|
325
|
+
const cmd = arg.cmd;
|
|
326
|
+
if (DYNAMIC_REGEX.test(cmd) && DYNAMIC_MAP[cmd]) {
|
|
327
|
+
marks.push({ markType: 'dynamic', type: DYNAMIC_MAP[cmd] });
|
|
328
|
+
}
|
|
329
|
+
else if (cmd === '<') {
|
|
330
|
+
marks.push({ markType: 'hairpin', type: HairpinType.crescendoStart });
|
|
331
|
+
}
|
|
332
|
+
else if (cmd === '>') {
|
|
333
|
+
marks.push({ markType: 'hairpin', type: HairpinType.diminuendoStart });
|
|
334
|
+
}
|
|
335
|
+
else if (cmd === '!') {
|
|
336
|
+
marks.push({ markType: 'hairpin', type: HairpinType.crescendoEnd }); // or diminuendoEnd
|
|
337
|
+
}
|
|
338
|
+
else if (cmd === 'sustainOn') {
|
|
339
|
+
marks.push({ markType: 'pedal', type: PedalType.sustainOn });
|
|
340
|
+
}
|
|
341
|
+
else if (cmd === 'sustainOff') {
|
|
342
|
+
marks.push({ markType: 'pedal', type: PedalType.sustainOff });
|
|
343
|
+
}
|
|
344
|
+
else if (cmd === 'coda') {
|
|
345
|
+
marks.push({ markType: 'navigation', type: NavigationMarkType.coda });
|
|
346
|
+
}
|
|
347
|
+
else if (cmd === 'segno') {
|
|
348
|
+
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
349
|
+
}
|
|
350
|
+
else if (cmd === '\\markup' || cmd === 'markup') {
|
|
351
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
352
|
+
const harmony = extractHarmonyFromMarkup(arg.args);
|
|
353
|
+
if (harmony) {
|
|
354
|
+
harmonyText = harmony;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// Regular markup attached to note
|
|
358
|
+
const text = extractTextFromObject(arg.args);
|
|
359
|
+
if (text && !containsTempoWord(text)) {
|
|
360
|
+
const direction = event.direction;
|
|
361
|
+
const placement = direction === 'up' ? Placement.above :
|
|
362
|
+
direction === 'down' ? Placement.below : undefined;
|
|
363
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Handle markup command directly (proto: 'MarkupCommand')
|
|
369
|
+
if (arg && typeof arg === 'object' && arg.proto === 'MarkupCommand') {
|
|
370
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
371
|
+
const harmony = extractHarmonyFromMarkup(arg.args);
|
|
372
|
+
if (harmony) {
|
|
373
|
+
harmonyText = harmony;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const text = extractTextFromObject(arg.args);
|
|
377
|
+
if (text && !containsTempoWord(text)) {
|
|
378
|
+
const direction = event.direction;
|
|
379
|
+
const placement = direction === 'up' ? Placement.above :
|
|
380
|
+
direction === 'down' ? Placement.below : undefined;
|
|
381
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { marks, harmonyText };
|
|
388
|
+
};
|
|
389
|
+
// Parse a LilyPond document to measures
|
|
390
|
+
const parseLilyDocument = (lilyDocument) => {
|
|
391
|
+
const measureMap = new Map();
|
|
392
|
+
const staffNames = [];
|
|
393
|
+
const interpreter = lilyDocument.interpret();
|
|
394
|
+
// Pre-compute partIndex for each track using staff-number-sequence heuristic.
|
|
395
|
+
// When staff numbers reset (decrease), a new part starts — this handles multi-PianoStaff
|
|
396
|
+
// scores where both pianos reuse the same staff names ("1" and "2").
|
|
397
|
+
// Scores that embed partIndex in the staff name ("1_1", "1_2", "2_1") are handled
|
|
398
|
+
// by parseStaffName; this heuristic only kicks in for plain numeric names.
|
|
399
|
+
let _seqPart = 1;
|
|
400
|
+
let _seqMaxStaff = 0;
|
|
401
|
+
const _trackPartIndices = interpreter.layoutMusic.musicTracks.map((track) => {
|
|
402
|
+
const staffName = track.contextDict?.Staff;
|
|
403
|
+
if (staffName && /^\d+_\d+$/.test(staffName)) {
|
|
404
|
+
// Staff name encodes partIndex explicitly — don't apply heuristic
|
|
405
|
+
return parseInt(staffName.split('_')[0], 10);
|
|
406
|
+
}
|
|
407
|
+
// PianoStaff is present (even as empty string "") when inside a grand-staff group;
|
|
408
|
+
// undefined means standalone instrument → different part from grand-staff tracks.
|
|
409
|
+
const hasPianoStaff = (track.contextDict?.PianoStaff !== undefined);
|
|
410
|
+
const staffNum = parseInt(staffName || '1', 10) || 1;
|
|
411
|
+
if (hasPianoStaff) {
|
|
412
|
+
// Grand-staff: new part if transitioning from standalone or staff resets
|
|
413
|
+
if (_seqMaxStaff > 0 && staffNum < _seqMaxStaff) {
|
|
414
|
+
_seqPart++;
|
|
415
|
+
_seqMaxStaff = 0;
|
|
416
|
+
}
|
|
417
|
+
else if (_seqMaxStaff === -1) {
|
|
418
|
+
// Transitioning from a standalone group
|
|
419
|
+
_seqPart++;
|
|
420
|
+
_seqMaxStaff = 0;
|
|
421
|
+
}
|
|
422
|
+
_seqMaxStaff = Math.max(_seqMaxStaff, staffNum);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
// Standalone: mark with -1 so next grand-staff group increments
|
|
426
|
+
_seqMaxStaff = -1;
|
|
427
|
+
}
|
|
428
|
+
return _seqPart;
|
|
429
|
+
});
|
|
430
|
+
interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
|
|
431
|
+
const appendStaff = (staffName) => {
|
|
432
|
+
if (!staffNames.includes(staffName)) {
|
|
433
|
+
staffNames.push(staffName);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
// Parse staff name to extract partIndex and staff number
|
|
437
|
+
// Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
|
|
438
|
+
// Falls back to positional ordering for non-numeric names (e.g., "upper"→1, "lower"→2)
|
|
439
|
+
const parseStaffName = (name) => {
|
|
440
|
+
const match = name.match(/^(\d+)_(\d+)$/);
|
|
441
|
+
if (match) {
|
|
442
|
+
return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
|
|
443
|
+
}
|
|
444
|
+
const num = parseInt(name, 10);
|
|
445
|
+
if (!isNaN(num)) {
|
|
446
|
+
return { partIndex: 1, staffNum: num };
|
|
447
|
+
}
|
|
448
|
+
// Non-numeric name: assign staffNum by order of first appearance in staffNames
|
|
449
|
+
appendStaff(name);
|
|
450
|
+
const idx = staffNames.indexOf(name);
|
|
451
|
+
return { partIndex: 1, staffNum: idx + 1 };
|
|
452
|
+
};
|
|
453
|
+
// Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
|
|
454
|
+
// This won't be affected by \change Staff commands inside the track
|
|
455
|
+
const initialStaffName = track.contextDict?.Staff;
|
|
456
|
+
if (initialStaffName) {
|
|
457
|
+
appendStaff(initialStaffName);
|
|
458
|
+
}
|
|
459
|
+
// Empty string staff name ("") means unnamed staff — treat as staff 1 within its part
|
|
460
|
+
const parsedStaff = (initialStaffName != null && initialStaffName !== '')
|
|
461
|
+
? parseStaffName(initialStaffName)
|
|
462
|
+
: { partIndex: 1, staffNum: 1 };
|
|
463
|
+
// Use these as fixed values for this track - don't update from context.staffName
|
|
464
|
+
const trackStaff = parsedStaff.staffNum;
|
|
465
|
+
// Use sequence-based partIndex (detects multi-PianoStaff via staff number reset)
|
|
466
|
+
const trackPartIndex = _trackPartIndices[vi] ?? parsedStaff.partIndex;
|
|
467
|
+
// Track emitted context events across measures for this voice
|
|
468
|
+
let lastKey = undefined; // Track value changes (key fifths)
|
|
469
|
+
let lastTimeSig = undefined; // Track value changes (as string for comparison)
|
|
470
|
+
let lastClef = undefined; // Track value changes
|
|
471
|
+
let lastOttava = undefined; // Track value changes
|
|
472
|
+
let lastStemDirection = undefined; // Track value changes
|
|
473
|
+
let partialEmitted = false; // Emit \partial context once per track
|
|
474
|
+
const context = new lilyParser.TrackContext(undefined, {
|
|
475
|
+
listener: (term, context) => {
|
|
476
|
+
const mi = term._measure;
|
|
477
|
+
if (mi === undefined)
|
|
478
|
+
return;
|
|
479
|
+
if (!measureMap.has(mi)) {
|
|
480
|
+
measureMap.set(mi, {
|
|
481
|
+
key: null,
|
|
482
|
+
timeSig: null,
|
|
483
|
+
voices: [],
|
|
484
|
+
partial: false,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const measure = measureMap.get(mi);
|
|
488
|
+
// Initialize voice for this track (use fixed staff/part from track definition)
|
|
489
|
+
if (!measure.voices[vi]) {
|
|
490
|
+
measure.voices[vi] = {
|
|
491
|
+
staff: trackStaff,
|
|
492
|
+
partIndex: trackPartIndex,
|
|
493
|
+
events: [],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const voice = measure.voices[vi];
|
|
497
|
+
// Update key/time/partial from context on music events
|
|
498
|
+
if (term instanceof lilyParser.MusicEvent ||
|
|
499
|
+
term instanceof lilyParser.LilyTerms.StemDirection ||
|
|
500
|
+
term instanceof lilyParser.LilyTerms.OctaveShift ||
|
|
501
|
+
term instanceof lilyParser.LilyTerms.Change ||
|
|
502
|
+
term instanceof lilyParser.LilyTerms.Partial) {
|
|
503
|
+
if (context.key && measure.key === null) {
|
|
504
|
+
measure.key = context.key.key;
|
|
505
|
+
}
|
|
506
|
+
if (context.time && measure.timeSig === null) {
|
|
507
|
+
measure.timeSig = {
|
|
508
|
+
numerator: context.time.value.numerator,
|
|
509
|
+
denominator: context.time.value.denominator,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (context.partialDuration) {
|
|
513
|
+
measure.partial = true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Emit \partial context event when the Partial term fires (before partialDuration is cleared)
|
|
517
|
+
if (term instanceof lilyParser.LilyTerms.Partial && context.partialDuration && !partialEmitted) {
|
|
518
|
+
const mag = context.partialDuration.magnitude;
|
|
519
|
+
const WHOLE = 1920;
|
|
520
|
+
let division = 1, dots = 0;
|
|
521
|
+
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
522
|
+
const base = WHOLE / div;
|
|
523
|
+
if (Math.abs(mag - base) < 1) {
|
|
524
|
+
division = div;
|
|
525
|
+
dots = 0;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
if (Math.abs(mag - Math.round(base * 1.5)) < 1) {
|
|
529
|
+
division = div;
|
|
530
|
+
dots = 1;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
if (Math.abs(mag - Math.round(base * 1.75)) < 1) {
|
|
534
|
+
division = div;
|
|
535
|
+
dots = 2;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
voice.events.push({ type: 'context', partial: { division, dots } });
|
|
540
|
+
partialEmitted = true;
|
|
541
|
+
}
|
|
542
|
+
// Handle music events
|
|
543
|
+
if (term instanceof lilyParser.MusicEvent) {
|
|
544
|
+
// Staff is fixed per track (from track definition)
|
|
545
|
+
voice.staff = trackStaff;
|
|
546
|
+
// Handle key context change (emit when value changes)
|
|
547
|
+
if (context.key && context.key.key !== lastKey) {
|
|
548
|
+
const key = convertKeySignature(context.key);
|
|
549
|
+
if (key) {
|
|
550
|
+
voice.events.push({
|
|
551
|
+
type: 'context',
|
|
552
|
+
key,
|
|
553
|
+
});
|
|
554
|
+
lastKey = context.key.key;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Handle time signature context change (emit when value changes)
|
|
558
|
+
if (context.time) {
|
|
559
|
+
const timeSigStr = `${context.time.value.numerator}/${context.time.value.denominator}`;
|
|
560
|
+
if (timeSigStr !== lastTimeSig) {
|
|
561
|
+
voice.events.push({
|
|
562
|
+
type: 'context',
|
|
563
|
+
time: {
|
|
564
|
+
numerator: context.time.value.numerator,
|
|
565
|
+
denominator: context.time.value.denominator,
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
lastTimeSig = timeSigStr;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Handle clef context change (emit when value changes)
|
|
572
|
+
if (context.clef) {
|
|
573
|
+
const clef = LILYPOND_CLEF_MAP[context.clef.clefName];
|
|
574
|
+
if (clef && clef !== lastClef) {
|
|
575
|
+
voice.events.push({
|
|
576
|
+
type: 'context',
|
|
577
|
+
clef,
|
|
578
|
+
});
|
|
579
|
+
lastClef = clef;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Handle ottava (emit when value changes)
|
|
583
|
+
if (context.octave != null) {
|
|
584
|
+
const currentOttava = context.octave.value ?? 0;
|
|
585
|
+
if (currentOttava !== lastOttava) {
|
|
586
|
+
voice.events.push({
|
|
587
|
+
type: 'context',
|
|
588
|
+
ottava: currentOttava,
|
|
589
|
+
});
|
|
590
|
+
lastOttava = currentOttava;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// Handle stem direction context change (emit when value changes)
|
|
594
|
+
if (context.stemDirection && context.stemDirection !== lastStemDirection) {
|
|
595
|
+
const stemDir = context.stemDirection === 'Up' ? StemDirection.up :
|
|
596
|
+
context.stemDirection === 'Down' ? StemDirection.down : undefined;
|
|
597
|
+
if (stemDir) {
|
|
598
|
+
voice.events.push({
|
|
599
|
+
type: 'context',
|
|
600
|
+
stemDirection: stemDir,
|
|
601
|
+
});
|
|
602
|
+
lastStemDirection = context.stemDirection;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// Process Chord (note or chord, or positioned rest if isRest)
|
|
606
|
+
if (term instanceof lilyParser.LilyTerms.Chord) {
|
|
607
|
+
const pitches = [];
|
|
608
|
+
for (const pitch of term.pitchesValue) {
|
|
609
|
+
if (pitch instanceof lilyParser.LilyTerms.ChordElement) {
|
|
610
|
+
pitches.push(convertPitch(pitch.phonetStep, pitch.alterValue || 0, pitch.absolutePitch.octave));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (pitches.length > 0) {
|
|
614
|
+
// Check if this is a positioned rest (a\rest syntax)
|
|
615
|
+
if (term.isRest) {
|
|
616
|
+
const restEvent = {
|
|
617
|
+
type: 'rest',
|
|
618
|
+
duration: convertDuration(term.durationValue),
|
|
619
|
+
pitch: pitches[0], // Use first pitch for positioning
|
|
620
|
+
};
|
|
621
|
+
voice.events.push(restEvent);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
const { marks, harmonyText } = parsePostEvents(term.post_events);
|
|
625
|
+
// Add beam marks
|
|
626
|
+
if (term.beamOn) {
|
|
627
|
+
marks.push({ markType: 'beam', start: true });
|
|
628
|
+
}
|
|
629
|
+
else if (term.beamOff) {
|
|
630
|
+
marks.push({ markType: 'beam', start: false });
|
|
631
|
+
}
|
|
632
|
+
// Add tie
|
|
633
|
+
if (term.isTying) {
|
|
634
|
+
marks.push({ markType: 'tie', start: true });
|
|
635
|
+
}
|
|
636
|
+
const noteEvent = {
|
|
637
|
+
type: 'note',
|
|
638
|
+
pitches,
|
|
639
|
+
duration: convertDuration(term.durationValue),
|
|
640
|
+
grace: context.inGrace || undefined,
|
|
641
|
+
};
|
|
642
|
+
if (marks.length > 0) {
|
|
643
|
+
noteEvent.marks = marks;
|
|
644
|
+
}
|
|
645
|
+
voice.events.push(noteEvent);
|
|
646
|
+
// Add harmony event if detected (chord symbol encoded as \bold markup)
|
|
647
|
+
if (harmonyText) {
|
|
648
|
+
const harmonyEvent = {
|
|
649
|
+
type: 'harmony',
|
|
650
|
+
text: harmonyText,
|
|
651
|
+
};
|
|
652
|
+
voice.events.push(harmonyEvent);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Process Rest
|
|
658
|
+
else if (term instanceof lilyParser.LilyTerms.Rest) {
|
|
659
|
+
// Ignore spacer rests inside grace contexts (e.g. \acciaccatura s8,
|
|
660
|
+
// \grace s4 — these are notation-only placeholders with no musical content)
|
|
661
|
+
if (!(term.isSpacer && context.inGrace)) {
|
|
662
|
+
const restEvent = {
|
|
663
|
+
type: 'rest',
|
|
664
|
+
duration: convertDuration(term.durationValue),
|
|
665
|
+
fullMeasure: (term.name === 'R') || undefined,
|
|
666
|
+
invisible: term.isSpacer || undefined,
|
|
667
|
+
};
|
|
668
|
+
voice.events.push(restEvent);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Handle standalone stem direction (emit when value changes)
|
|
673
|
+
else if (term instanceof lilyParser.LilyTerms.StemDirection) {
|
|
674
|
+
if (term.direction !== lastStemDirection) {
|
|
675
|
+
const stemDir = term.direction === 'Up' ? StemDirection.up :
|
|
676
|
+
term.direction === 'Down' ? StemDirection.down : undefined;
|
|
677
|
+
if (stemDir) {
|
|
678
|
+
voice.events.push({
|
|
679
|
+
type: 'context',
|
|
680
|
+
stemDirection: stemDir,
|
|
681
|
+
});
|
|
682
|
+
lastStemDirection = term.direction;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// Handle standalone clef (emit when value changes)
|
|
687
|
+
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
688
|
+
const clef = LILYPOND_CLEF_MAP[term.clefName];
|
|
689
|
+
if (clef && clef !== lastClef) {
|
|
690
|
+
voice.events.push({
|
|
691
|
+
type: 'context',
|
|
692
|
+
clef,
|
|
693
|
+
});
|
|
694
|
+
lastClef = clef;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Handle ottava shift
|
|
698
|
+
else if (term instanceof lilyParser.LilyTerms.OctaveShift) {
|
|
699
|
+
if (term.value !== lastOttava) {
|
|
700
|
+
voice.events.push({
|
|
701
|
+
type: 'context',
|
|
702
|
+
ottava: term.value,
|
|
703
|
+
});
|
|
704
|
+
lastOttava = term.value;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Handle staff change (\change Staff = "N")
|
|
708
|
+
// args[0] is Assignment { key: "Staff", value: { exp: '"N"' } }
|
|
709
|
+
else if (term instanceof lilyParser.LilyTerms.Change) {
|
|
710
|
+
const assignment = term.args?.[0];
|
|
711
|
+
if (assignment?.key === 'Staff') {
|
|
712
|
+
// exp is like '"2"' — strip surrounding quotes then parse strictly
|
|
713
|
+
const exp = assignment?.value?.exp ?? '';
|
|
714
|
+
const raw = exp.replace(/^"|"$/g, '');
|
|
715
|
+
// Only accept pure integer staff names (e.g. "1", "2")
|
|
716
|
+
// Reject compound names like "1_2", "RH", "LH"
|
|
717
|
+
const staffNum = /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
|
|
718
|
+
if (!isNaN(staffNum)) {
|
|
719
|
+
voice.events.push({ type: 'context', staff: staffNum });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// Handle tempo
|
|
724
|
+
else if (term instanceof lilyParser.LilyTerms.Tempo) {
|
|
725
|
+
const tempo = convertTempo(term);
|
|
726
|
+
if (tempo) {
|
|
727
|
+
voice.events.push({
|
|
728
|
+
type: 'context',
|
|
729
|
+
tempo,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// Handle standalone markup command and barlines
|
|
734
|
+
else {
|
|
735
|
+
const termAny = term;
|
|
736
|
+
if (termAny.proto === 'Command' && (termAny.cmd === '\\markup' || termAny.cmd === 'markup')) {
|
|
737
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
738
|
+
const harmonyText = extractHarmonyFromMarkup(termAny.args);
|
|
739
|
+
if (harmonyText) {
|
|
740
|
+
const harmonyEvent = {
|
|
741
|
+
type: 'harmony',
|
|
742
|
+
text: harmonyText,
|
|
743
|
+
};
|
|
744
|
+
voice.events.push(harmonyEvent);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
const text = extractTextFromObject(termAny.args);
|
|
748
|
+
if (text && !containsTempoWord(text)) {
|
|
749
|
+
const markupEvent = {
|
|
750
|
+
type: 'markup',
|
|
751
|
+
content: text,
|
|
752
|
+
};
|
|
753
|
+
voice.events.push(markupEvent);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Handle barline command - barlines belong to the previous measure
|
|
758
|
+
else if (termAny.proto === 'Command' && termAny.cmd === 'bar') {
|
|
759
|
+
const style = termAny.args?.[0]?.exp;
|
|
760
|
+
if (style && mi > 0) {
|
|
761
|
+
// Remove quotes from the style string
|
|
762
|
+
const barStyle = style.replace(/^"|"$/g, '');
|
|
763
|
+
// Add to previous measure's voice
|
|
764
|
+
const prevMeasure = measureMap.get(mi - 1);
|
|
765
|
+
if (prevMeasure && prevMeasure.voices[vi]) {
|
|
766
|
+
prevMeasure.voices[vi].events.push({
|
|
767
|
+
type: 'barline',
|
|
768
|
+
style: barStyle,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Handle ChordSymbol (inline chord symbol: \chords "text")
|
|
774
|
+
else if (termAny.proto === 'ChordSymbol') {
|
|
775
|
+
// Extract text from LiteralString (e.g., { exp: '"C"' } -> "C")
|
|
776
|
+
let text = termAny.text;
|
|
777
|
+
if (typeof text === 'object' && text?.exp) {
|
|
778
|
+
text = text.exp.replace(/^"|"$/g, '');
|
|
779
|
+
}
|
|
780
|
+
else if (typeof text === 'string') {
|
|
781
|
+
text = text.replace(/^"|"$/g, '');
|
|
782
|
+
}
|
|
783
|
+
const harmonyEvent = {
|
|
784
|
+
type: 'harmony',
|
|
785
|
+
text: text,
|
|
786
|
+
};
|
|
787
|
+
voice.events.push(harmonyEvent);
|
|
788
|
+
}
|
|
789
|
+
// Handle tuplet
|
|
790
|
+
// Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
|
|
791
|
+
// remove the already-added notes and wrap them in a TupletEvent
|
|
792
|
+
else if (termAny.proto === 'Tuplet' || termAny.proto === 'Times') {
|
|
793
|
+
const isTimes = termAny.proto === 'Times';
|
|
794
|
+
const ratioStr = termAny.args?.[0]; // "3/2" for \tuplet, "2/3" for \times
|
|
795
|
+
// \tuplet supports an optional base-duration arg: \tuplet 3/2 4 { notes }
|
|
796
|
+
// making args = [ratio, baseDur?, body]. Use the last arg (= music block),
|
|
797
|
+
// matching what lotus Tuplet.music getter does: this.args[this.args.length-1]
|
|
798
|
+
const body = termAny.args?.[termAny.args.length - 1]?.body || [];
|
|
799
|
+
if (ratioStr && body.length > 0) {
|
|
800
|
+
const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
|
|
801
|
+
if (ratioMatch) {
|
|
802
|
+
const [, num, denom] = ratioMatch;
|
|
803
|
+
// \tuplet 3/2: divider = 3/2, lilylet ratio = 2/3 → swap
|
|
804
|
+
// \times 2/3: factor = 2/3, lilylet ratio = 2/3 → no swap
|
|
805
|
+
const ratio = isTimes
|
|
806
|
+
? { numerator: parseInt(num, 10), denominator: parseInt(denom, 10) }
|
|
807
|
+
: { numerator: parseInt(denom, 10), denominator: parseInt(num, 10) };
|
|
808
|
+
// Count sounding (non-grace) notes/rests in the tuplet body,
|
|
809
|
+
// recursing into AfterGrace.body (main note) but not AfterGrace.grace.
|
|
810
|
+
// Grace / Acciaccatura / Appoggiatura blocks count as 0.
|
|
811
|
+
const countSounding = (items) => {
|
|
812
|
+
let n = 0;
|
|
813
|
+
for (const item of items) {
|
|
814
|
+
if (!item)
|
|
815
|
+
continue;
|
|
816
|
+
switch (item.proto) {
|
|
817
|
+
case 'Chord':
|
|
818
|
+
case 'Rest':
|
|
819
|
+
n++;
|
|
820
|
+
break;
|
|
821
|
+
case 'AfterGrace': {
|
|
822
|
+
// args[0] = main note, args[1] = after-grace notes (0-dur, skip)
|
|
823
|
+
const main = item.args?.[0];
|
|
824
|
+
if (main?.proto === 'Chord' || main?.proto === 'Rest')
|
|
825
|
+
n++;
|
|
826
|
+
else if (main?.body)
|
|
827
|
+
n += countSounding(main.body);
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
case 'Grace':
|
|
831
|
+
case 'Acciaccatura':
|
|
832
|
+
case 'Appoggiatura': break;
|
|
833
|
+
default:
|
|
834
|
+
if (item.body)
|
|
835
|
+
n += countSounding(item.body);
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return n;
|
|
840
|
+
};
|
|
841
|
+
const noteCount = countSounding(body);
|
|
842
|
+
const tupletEvents = [];
|
|
843
|
+
let removed = 0;
|
|
844
|
+
// Pop notes/rests from voice.events, skipping context events.
|
|
845
|
+
// Grace notes are moved inside the tuplet but do NOT consume a
|
|
846
|
+
// sounding-note slot (they have 0 duration in the time signature).
|
|
847
|
+
while (removed < noteCount && voice.events.length > 0) {
|
|
848
|
+
const lastEvent = voice.events[voice.events.length - 1];
|
|
849
|
+
if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
|
|
850
|
+
tupletEvents.unshift(voice.events.pop());
|
|
851
|
+
if (!lastEvent.grace)
|
|
852
|
+
removed++;
|
|
853
|
+
}
|
|
854
|
+
else if (lastEvent.type === 'context' || lastEvent.type === 'pitchReset') {
|
|
855
|
+
// Context event between tuplet notes — move it inside tuplet too
|
|
856
|
+
tupletEvents.unshift(voice.events.pop());
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (tupletEvents.length > 0) {
|
|
863
|
+
voice.events.push(isTimes
|
|
864
|
+
? { type: 'times', ratio, events: tupletEvents }
|
|
865
|
+
: { type: 'tuplet', ratio, events: tupletEvents });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// Handle repeat tremolo
|
|
871
|
+
else if (termAny.proto === 'Repeat' && termAny.args?.[0] === 'tremolo') {
|
|
872
|
+
const count = parseInt(termAny.args?.[1], 10);
|
|
873
|
+
const body = termAny.args?.[2]?.body || [];
|
|
874
|
+
if (!isNaN(count) && body.length === 2) {
|
|
875
|
+
// Double tremolo has exactly 2 pitches
|
|
876
|
+
const pitch1 = body[0]?.pitches?.[0]?.pitch;
|
|
877
|
+
const pitch2 = body[1]?.pitches?.[0]?.pitch;
|
|
878
|
+
const duration = body[0]?.duration;
|
|
879
|
+
if (pitch1 && pitch2 && duration) {
|
|
880
|
+
const pitchA = parseRawPitch(pitch1);
|
|
881
|
+
const pitchB = parseRawPitch(pitch2);
|
|
882
|
+
const div = parseInt(duration.number, 10);
|
|
883
|
+
if (pitchA && pitchB && !isNaN(div)) {
|
|
884
|
+
// Remove the 2 notes that were already added
|
|
885
|
+
let removed = 0;
|
|
886
|
+
while (removed < 2 && voice.events.length > 0) {
|
|
887
|
+
const lastEvent = voice.events[voice.events.length - 1];
|
|
888
|
+
if (lastEvent.type === 'note') {
|
|
889
|
+
voice.events.pop();
|
|
890
|
+
removed++;
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const tremoloEvent = {
|
|
897
|
+
type: 'tremolo',
|
|
898
|
+
pitchA: [pitchA],
|
|
899
|
+
pitchB: [pitchB],
|
|
900
|
+
count,
|
|
901
|
+
division: div,
|
|
902
|
+
};
|
|
903
|
+
voice.events.push(tremoloEvent);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
context.execute(track.music);
|
|
912
|
+
// Post-process: carry staff state across measure boundaries for this voice.
|
|
913
|
+
// If measure N ends on staff 2 (via \change Staff), measure N+1 must begin
|
|
914
|
+
// with context { staff: 2 } so notes at the start of that measure serialize
|
|
915
|
+
// on the correct staff.
|
|
916
|
+
{
|
|
917
|
+
const measureIndices = Array.from(measureMap.keys()).sort((a, b) => a - b);
|
|
918
|
+
let carryStaff = trackStaff;
|
|
919
|
+
for (const mi of measureIndices) {
|
|
920
|
+
const voice = measureMap.get(mi)?.voices[vi];
|
|
921
|
+
if (!voice)
|
|
922
|
+
continue;
|
|
923
|
+
// Prepend carry-over event if previous measure ended on a different staff,
|
|
924
|
+
// but skip if the voice's first explicit staff event already resets to
|
|
925
|
+
// trackStaff — the carry-over would be immediately cancelled (no-op).
|
|
926
|
+
if (carryStaff !== trackStaff) {
|
|
927
|
+
// Suppress carry-over if the first staff event resets to trackStaff
|
|
928
|
+
// AND no musical events (notes/rests/etc.) precede it — meaning the
|
|
929
|
+
// carry-over would be immediately cancelled with no effect.
|
|
930
|
+
const firstStaffCtxIdx = voice.events.findIndex(e => e.type === 'context' && e.staff != null);
|
|
931
|
+
const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
932
|
+
const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
|
|
933
|
+
voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
|
|
934
|
+
const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
|
|
935
|
+
voice.events[firstStaffCtxIdx].staff === trackStaff &&
|
|
936
|
+
!hasMusicBeforeFirstStaff;
|
|
937
|
+
if (!immediatelyCancelled) {
|
|
938
|
+
voice.events.unshift({ type: 'context', staff: carryStaff });
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// Update carryStaff from this measure's events (including inside tuplets)
|
|
942
|
+
const scanStaff = (events) => {
|
|
943
|
+
for (const e of events) {
|
|
944
|
+
if (e.type === 'context' && e.staff) {
|
|
945
|
+
carryStaff = e.staff;
|
|
946
|
+
}
|
|
947
|
+
else if ((e.type === 'tuplet' || e.type === 'times') && e.events) {
|
|
948
|
+
scanStaff(e.events);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
scanStaff(voice.events);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
// Filter out empty voices and convert to array, sorted by measure number
|
|
957
|
+
const measures = Array.from(measureMap.entries())
|
|
958
|
+
.sort(([a], [b]) => a - b)
|
|
959
|
+
.map(([, measure]) => measure);
|
|
960
|
+
for (const measure of measures) {
|
|
961
|
+
measure.voices = measure.voices.filter(Boolean);
|
|
962
|
+
}
|
|
963
|
+
return measures;
|
|
964
|
+
};
|
|
965
|
+
// Check if a voice has real music content (not just spacer rests and context changes)
|
|
966
|
+
const hasRealContent = (events) => {
|
|
967
|
+
return events.some(e => {
|
|
968
|
+
if (e.type === 'note')
|
|
969
|
+
return true;
|
|
970
|
+
if (e.type === 'rest' && !e.invisible)
|
|
971
|
+
return true;
|
|
972
|
+
if (e.type === 'tuplet')
|
|
973
|
+
return true;
|
|
974
|
+
if (e.type === 'times')
|
|
975
|
+
return true;
|
|
976
|
+
if (e.type === 'tremolo')
|
|
977
|
+
return true;
|
|
978
|
+
return false;
|
|
979
|
+
});
|
|
980
|
+
};
|
|
981
|
+
// Remove quotes from string literal
|
|
982
|
+
const unquoteString = (str) => {
|
|
983
|
+
if (str.startsWith('"') && str.endsWith('"')) {
|
|
984
|
+
return str.slice(1, -1);
|
|
985
|
+
}
|
|
986
|
+
return str;
|
|
987
|
+
};
|
|
988
|
+
// Extract text from lotus parser objects recursively
|
|
989
|
+
const extractTextFromObject = (obj) => {
|
|
990
|
+
if (!obj)
|
|
991
|
+
return undefined;
|
|
992
|
+
// Simple string
|
|
993
|
+
if (typeof obj === 'string') {
|
|
994
|
+
return obj;
|
|
995
|
+
}
|
|
996
|
+
// Array - concatenate all text
|
|
997
|
+
if (Array.isArray(obj)) {
|
|
998
|
+
const texts = [];
|
|
999
|
+
for (const item of obj) {
|
|
1000
|
+
const text = extractTextFromObject(item);
|
|
1001
|
+
if (text)
|
|
1002
|
+
texts.push(text);
|
|
1003
|
+
}
|
|
1004
|
+
return texts.join(' ').trim() || undefined;
|
|
1005
|
+
}
|
|
1006
|
+
// Object with proto property (lotus parser objects)
|
|
1007
|
+
if (obj && typeof obj === 'object' && obj.proto) {
|
|
1008
|
+
switch (obj.proto) {
|
|
1009
|
+
case 'LiteralString':
|
|
1010
|
+
// exp contains quoted string like '"Hello"'
|
|
1011
|
+
if (obj.exp) {
|
|
1012
|
+
return unquoteString(obj.exp);
|
|
1013
|
+
}
|
|
1014
|
+
break;
|
|
1015
|
+
case 'MarkupCommand':
|
|
1016
|
+
case 'Command':
|
|
1017
|
+
// Recursively extract from args
|
|
1018
|
+
if (obj.args) {
|
|
1019
|
+
return extractTextFromObject(obj.args);
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
case 'InlineBlock':
|
|
1023
|
+
// Extract from body, skip primitive commands
|
|
1024
|
+
if (obj.body) {
|
|
1025
|
+
const texts = [];
|
|
1026
|
+
for (const item of obj.body) {
|
|
1027
|
+
if (item.proto !== 'Primitive') {
|
|
1028
|
+
const text = extractTextFromObject(item);
|
|
1029
|
+
if (text)
|
|
1030
|
+
texts.push(text);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return texts.join(' ').trim() || undefined;
|
|
1034
|
+
}
|
|
1035
|
+
break;
|
|
1036
|
+
case 'String':
|
|
1037
|
+
if (obj.value) {
|
|
1038
|
+
return obj.value;
|
|
1039
|
+
}
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Fallback: try value property
|
|
1044
|
+
if (obj.value !== undefined) {
|
|
1045
|
+
return extractTextFromObject(obj.value);
|
|
1046
|
+
}
|
|
1047
|
+
return undefined;
|
|
1048
|
+
};
|
|
1049
|
+
// Check if markup contains \bold command (indicates harmony/chord symbol)
|
|
1050
|
+
// Returns the text if it's a harmony, undefined otherwise
|
|
1051
|
+
const extractHarmonyFromMarkup = (obj) => {
|
|
1052
|
+
if (!obj)
|
|
1053
|
+
return undefined;
|
|
1054
|
+
// Check array of args
|
|
1055
|
+
if (Array.isArray(obj)) {
|
|
1056
|
+
for (const item of obj) {
|
|
1057
|
+
const result = extractHarmonyFromMarkup(item);
|
|
1058
|
+
if (result !== undefined)
|
|
1059
|
+
return result;
|
|
1060
|
+
}
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
if (obj && typeof obj === 'object') {
|
|
1064
|
+
// Check if this is a \bold command (can be Command or MarkupCommand)
|
|
1065
|
+
if ((obj.proto === 'Command' || obj.proto === 'MarkupCommand') &&
|
|
1066
|
+
(obj.cmd === 'bold' || obj.cmd === '\\bold')) {
|
|
1067
|
+
// Extract the text from args
|
|
1068
|
+
return extractTextFromObject(obj.args);
|
|
1069
|
+
}
|
|
1070
|
+
// Recursively search InlineBlock body
|
|
1071
|
+
if (obj.proto === 'InlineBlock' && obj.body) {
|
|
1072
|
+
return extractHarmonyFromMarkup(obj.body);
|
|
1073
|
+
}
|
|
1074
|
+
// Recursively search args
|
|
1075
|
+
if (obj.args) {
|
|
1076
|
+
return extractHarmonyFromMarkup(obj.args);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return undefined;
|
|
1080
|
+
};
|
|
1081
|
+
// Extract string value from header field
|
|
1082
|
+
const extractStringValue = (value) => {
|
|
1083
|
+
const text = extractTextFromObject(value);
|
|
1084
|
+
return text ? text.trim() : undefined;
|
|
1085
|
+
};
|
|
1086
|
+
// Extract metadata from LilyDocument
|
|
1087
|
+
const extractMetadata = (lilyDocument) => {
|
|
1088
|
+
try {
|
|
1089
|
+
const attrs = lilyDocument.globalAttributesReadOnly();
|
|
1090
|
+
const metadata = {};
|
|
1091
|
+
// Extract each field, handling markup structures
|
|
1092
|
+
if (attrs.title) {
|
|
1093
|
+
metadata.title = extractStringValue(attrs.title);
|
|
1094
|
+
}
|
|
1095
|
+
if (attrs.subtitle) {
|
|
1096
|
+
metadata.subtitle = extractStringValue(attrs.subtitle);
|
|
1097
|
+
}
|
|
1098
|
+
if (attrs.composer) {
|
|
1099
|
+
metadata.composer = extractStringValue(attrs.composer);
|
|
1100
|
+
}
|
|
1101
|
+
if (attrs.arranger) {
|
|
1102
|
+
metadata.arranger = extractStringValue(attrs.arranger);
|
|
1103
|
+
}
|
|
1104
|
+
if (attrs.poet) {
|
|
1105
|
+
metadata.lyricist = extractStringValue(attrs.poet);
|
|
1106
|
+
}
|
|
1107
|
+
if (attrs.opus) {
|
|
1108
|
+
metadata.opus = extractStringValue(attrs.opus);
|
|
1109
|
+
}
|
|
1110
|
+
if (attrs.instrument) {
|
|
1111
|
+
metadata.instrument = extractStringValue(attrs.instrument);
|
|
1112
|
+
}
|
|
1113
|
+
// Return undefined if no metadata fields were populated
|
|
1114
|
+
if (Object.keys(metadata).length === 0) {
|
|
1115
|
+
return undefined;
|
|
1116
|
+
}
|
|
1117
|
+
return metadata;
|
|
1118
|
+
}
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
// If metadata extraction fails, continue without it
|
|
1121
|
+
return undefined;
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
// Dedupe consecutive context events in merged voice
|
|
1125
|
+
// This is needed because multiple tracks merged into one voice may have redundant context events
|
|
1126
|
+
const dedupeContextEvents = (events) => {
|
|
1127
|
+
const result = [];
|
|
1128
|
+
let lastStemDirection;
|
|
1129
|
+
let lastClef;
|
|
1130
|
+
for (const e of events) {
|
|
1131
|
+
if (e.type === 'context') {
|
|
1132
|
+
const ctx = e;
|
|
1133
|
+
// Dedupe stemDirection
|
|
1134
|
+
if ('stemDirection' in ctx) {
|
|
1135
|
+
if (ctx.stemDirection === lastStemDirection)
|
|
1136
|
+
continue;
|
|
1137
|
+
lastStemDirection = ctx.stemDirection;
|
|
1138
|
+
}
|
|
1139
|
+
// Dedupe clef
|
|
1140
|
+
if ('clef' in ctx) {
|
|
1141
|
+
if (ctx.clef === lastClef)
|
|
1142
|
+
continue;
|
|
1143
|
+
lastClef = ctx.clef;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
result.push(e);
|
|
1147
|
+
}
|
|
1148
|
+
return result;
|
|
1149
|
+
};
|
|
1150
|
+
// Convert parsed measures to LilyletDoc
|
|
1151
|
+
const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
|
|
1152
|
+
const measures = parsedMeasures.map(pm => {
|
|
1153
|
+
// Filter out voices that only contain spacer rests and context changes
|
|
1154
|
+
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
1155
|
+
// Group voices by partIndex, then collect voice arrays per staff
|
|
1156
|
+
const partMap = new Map();
|
|
1157
|
+
for (const v of filteredVoices) {
|
|
1158
|
+
const pi = v.partIndex || 1;
|
|
1159
|
+
if (!partMap.has(pi)) {
|
|
1160
|
+
partMap.set(pi, new Map());
|
|
1161
|
+
}
|
|
1162
|
+
const staffMap = partMap.get(pi);
|
|
1163
|
+
// Preserve each voice as a separate array
|
|
1164
|
+
if (!staffMap.has(v.staff)) {
|
|
1165
|
+
staffMap.set(v.staff, []);
|
|
1166
|
+
}
|
|
1167
|
+
staffMap.get(v.staff).push(v.events);
|
|
1168
|
+
}
|
|
1169
|
+
// Convert to parts array (sorted by part index, then by staff)
|
|
1170
|
+
// Apply deduplication to each voice's events
|
|
1171
|
+
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1172
|
+
const parts = partIndices.map(pi => {
|
|
1173
|
+
const staffMap = partMap.get(pi);
|
|
1174
|
+
const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
|
|
1175
|
+
return {
|
|
1176
|
+
voices: staffNums.flatMap(staff => staffMap.get(staff).map(events => ({
|
|
1177
|
+
staff,
|
|
1178
|
+
events: dedupeContextEvents(events),
|
|
1179
|
+
}))),
|
|
1180
|
+
};
|
|
1181
|
+
});
|
|
1182
|
+
// Fallback to single empty part if no voices
|
|
1183
|
+
const measure = {
|
|
1184
|
+
parts: parts.length > 0 ? parts : [{ voices: [] }],
|
|
1185
|
+
};
|
|
1186
|
+
if (pm.key !== null) {
|
|
1187
|
+
measure.key = convertKeySignature(pm.key);
|
|
1188
|
+
}
|
|
1189
|
+
if (pm.timeSig) {
|
|
1190
|
+
measure.timeSig = pm.timeSig;
|
|
1191
|
+
}
|
|
1192
|
+
if (pm.partial) {
|
|
1193
|
+
measure.partial = true;
|
|
1194
|
+
}
|
|
1195
|
+
return measure;
|
|
1196
|
+
})
|
|
1197
|
+
// Filter out empty measures (no voices in any part)
|
|
1198
|
+
.filter(m => m.parts.some(p => p.voices.length > 0));
|
|
1199
|
+
const doc = { measures };
|
|
1200
|
+
if (metadata) {
|
|
1201
|
+
doc.metadata = metadata;
|
|
1202
|
+
}
|
|
1203
|
+
return doc;
|
|
1204
|
+
};
|
|
1205
|
+
/**
|
|
1206
|
+
* Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
|
|
1207
|
+
*/
|
|
1208
|
+
const decode = (lilypondSource) => {
|
|
1209
|
+
const rawData = lilypondParser.parse(lilypondSource);
|
|
1210
|
+
const lilyDocument = new lilyParser.LilyDocument(rawData);
|
|
1211
|
+
const parsedMeasures = parseLilyDocument(lilyDocument);
|
|
1212
|
+
const metadata = extractMetadata(lilyDocument);
|
|
1213
|
+
return parsedMeasuresToDoc(parsedMeasures, metadata);
|
|
1214
|
+
};
|
|
1215
|
+
/**
|
|
1216
|
+
* Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
|
|
1217
|
+
*/
|
|
1218
|
+
const decodeFromDocument = (lilyDocument) => {
|
|
1219
|
+
const parsedMeasures = parseLilyDocument(lilyDocument);
|
|
1220
|
+
const metadata = extractMetadata(lilyDocument);
|
|
1221
|
+
return parsedMeasuresToDoc(parsedMeasures, metadata);
|
|
1222
|
+
};
|
|
1223
|
+
export { decode, decodeFromDocument, parseLilyDocument, };
|