@k-l-lambda/lilylet 0.1.62 → 0.1.64
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/grammar.jison.js +300 -187
- package/lib/lilylet/abcDecoder.js +40 -12
- package/lib/lilylet/lilypondEncoder.js +3 -0
- package/lib/lilylet/meiEncoder.js +87 -48
- 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 +5 -2
- package/source/abc/abc.jison +90 -15
- package/source/abc/grammar.jison.js +300 -187
- package/source/lilylet/abcDecoder.ts +42 -14
- package/source/lilylet/lilypondEncoder.ts +2 -0
- package/source/lilylet/meiEncoder.ts +95 -48
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MusicXML Utility Functions
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for parsing MusicXML elements and converting values.
|
|
5
|
+
*/
|
|
6
|
+
import { Phonet, Accidental, Clef, StemDirection, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, } from "./types.js";
|
|
7
|
+
// ============ XML Element Helpers ============
|
|
8
|
+
/**
|
|
9
|
+
* Get text content of a child element by tag name
|
|
10
|
+
*/
|
|
11
|
+
export const getElementText = (parent, tagName) => {
|
|
12
|
+
const el = parent.getElementsByTagName(tagName)[0];
|
|
13
|
+
return el?.textContent?.trim() || undefined;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Get numeric content of a child element
|
|
17
|
+
*/
|
|
18
|
+
export const getElementNumber = (parent, tagName) => {
|
|
19
|
+
const text = getElementText(parent, tagName);
|
|
20
|
+
if (text === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
const num = parseFloat(text);
|
|
23
|
+
return isNaN(num) ? undefined : num;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Get integer content of a child element
|
|
27
|
+
*/
|
|
28
|
+
export const getElementInt = (parent, tagName) => {
|
|
29
|
+
const text = getElementText(parent, tagName);
|
|
30
|
+
if (text === undefined)
|
|
31
|
+
return undefined;
|
|
32
|
+
const num = parseInt(text, 10);
|
|
33
|
+
return isNaN(num) ? undefined : num;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Check if element has a child with given tag name
|
|
37
|
+
*/
|
|
38
|
+
export const hasElement = (parent, tagName) => {
|
|
39
|
+
return parent.getElementsByTagName(tagName).length > 0;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Get attribute value from element
|
|
43
|
+
*/
|
|
44
|
+
export const getAttribute = (el, name) => {
|
|
45
|
+
const attr = el.getAttribute(name);
|
|
46
|
+
return attr || undefined;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Get numeric attribute value
|
|
50
|
+
*/
|
|
51
|
+
export const getAttributeNumber = (el, name) => {
|
|
52
|
+
const text = getAttribute(el, name);
|
|
53
|
+
if (text === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
const num = parseFloat(text);
|
|
56
|
+
return isNaN(num) ? undefined : num;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Get all child elements with given tag name
|
|
60
|
+
*/
|
|
61
|
+
export const getElements = (parent, tagName) => {
|
|
62
|
+
return Array.from(parent.getElementsByTagName(tagName));
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Get all direct child elements (xmldom compatible - uses childNodes)
|
|
66
|
+
*/
|
|
67
|
+
export const getChildElements = (parent) => {
|
|
68
|
+
const result = [];
|
|
69
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
70
|
+
const node = parent.childNodes[i];
|
|
71
|
+
if (node.nodeType === 1) { // ELEMENT_NODE
|
|
72
|
+
result.push(node);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Get direct child elements (not nested)
|
|
79
|
+
* Note: Uses childNodes instead of children for xmldom compatibility
|
|
80
|
+
*/
|
|
81
|
+
export const getDirectChildren = (parent, tagName) => {
|
|
82
|
+
const result = [];
|
|
83
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
84
|
+
const node = parent.childNodes[i];
|
|
85
|
+
if (node.nodeType === 1 && node.tagName === tagName) {
|
|
86
|
+
result.push(node);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
};
|
|
91
|
+
// ============ Pitch Conversion ============
|
|
92
|
+
const STEP_TO_PHONET = {
|
|
93
|
+
C: Phonet.c,
|
|
94
|
+
D: Phonet.d,
|
|
95
|
+
E: Phonet.e,
|
|
96
|
+
F: Phonet.f,
|
|
97
|
+
G: Phonet.g,
|
|
98
|
+
A: Phonet.a,
|
|
99
|
+
B: Phonet.b,
|
|
100
|
+
};
|
|
101
|
+
const ALTER_TO_ACCIDENTAL = {
|
|
102
|
+
[-2]: Accidental.doubleFlat,
|
|
103
|
+
[-1]: Accidental.flat,
|
|
104
|
+
[1]: Accidental.sharp,
|
|
105
|
+
[2]: Accidental.doubleSharp,
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Convert MusicXML pitch to Lilylet Pitch
|
|
109
|
+
* MusicXML octave 4 = middle C octave = Lilylet octave 0
|
|
110
|
+
*/
|
|
111
|
+
export const convertPitch = (step, alter, octave) => {
|
|
112
|
+
const phonet = STEP_TO_PHONET[step.toUpperCase()];
|
|
113
|
+
if (!phonet) {
|
|
114
|
+
throw new Error(`Invalid pitch step: ${step}`);
|
|
115
|
+
}
|
|
116
|
+
const accidental = alter !== undefined && alter !== 0
|
|
117
|
+
? ALTER_TO_ACCIDENTAL[alter]
|
|
118
|
+
: undefined;
|
|
119
|
+
// MusicXML octave 4 = Lilylet octave 0
|
|
120
|
+
const lilyletOctave = octave - 4;
|
|
121
|
+
return {
|
|
122
|
+
phonet,
|
|
123
|
+
accidental,
|
|
124
|
+
octave: lilyletOctave,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
// ============ Duration Constants & Mappings ============
|
|
128
|
+
// Standard divisions per quarter note (shared by encoder/decoder)
|
|
129
|
+
export const DIVISIONS = 4;
|
|
130
|
+
// MusicXML note type to division (1=whole, 2=half, 4=quarter, etc.)
|
|
131
|
+
export const TYPE_TO_DIVISION = {
|
|
132
|
+
maxima: 0.125,
|
|
133
|
+
long: 0.25,
|
|
134
|
+
breve: 0.5,
|
|
135
|
+
whole: 1,
|
|
136
|
+
half: 2,
|
|
137
|
+
quarter: 4,
|
|
138
|
+
eighth: 8,
|
|
139
|
+
'16th': 16,
|
|
140
|
+
'32nd': 32,
|
|
141
|
+
'64th': 64,
|
|
142
|
+
'128th': 128,
|
|
143
|
+
'256th': 256,
|
|
144
|
+
'512th': 512,
|
|
145
|
+
'1024th': 1024,
|
|
146
|
+
};
|
|
147
|
+
// Division to MusicXML note type (inverse of TYPE_TO_DIVISION)
|
|
148
|
+
export const DIVISION_TO_TYPE = Object.fromEntries(Object.entries(TYPE_TO_DIVISION).map(([type, div]) => [div, type]));
|
|
149
|
+
/**
|
|
150
|
+
* Calculate duration in MusicXML divisions.
|
|
151
|
+
* Shared by encoder (with DIVISIONS=4) and potentially decoder.
|
|
152
|
+
*
|
|
153
|
+
* Duration.tuplet is in Lilylet ratio semantics:
|
|
154
|
+
* \times 2/3 → {numerator:2, denominator:3} → multiply by 2/3
|
|
155
|
+
*/
|
|
156
|
+
export const calculateDuration = (duration, divisions = DIVISIONS) => {
|
|
157
|
+
// Base duration: divisions * (4 / division)
|
|
158
|
+
// e.g., quarter (4) = divisions * 1
|
|
159
|
+
// half (2) = divisions * 2
|
|
160
|
+
// eighth (8) = divisions * 0.5
|
|
161
|
+
let dur = divisions * (4 / duration.division);
|
|
162
|
+
// Apply dots
|
|
163
|
+
if (duration.dots) {
|
|
164
|
+
let dotValue = dur / 2;
|
|
165
|
+
for (let i = 0; i < duration.dots; i++) {
|
|
166
|
+
dur += dotValue;
|
|
167
|
+
dotValue /= 2;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Apply tuplet ratio: Lilylet ratio num/den means multiply by num/den
|
|
171
|
+
// e.g., \times 2/3 means each note's actual duration = written * 2/3
|
|
172
|
+
if (duration.tuplet) {
|
|
173
|
+
dur = dur * duration.tuplet.numerator / duration.tuplet.denominator;
|
|
174
|
+
}
|
|
175
|
+
return Math.round(dur);
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Convert MusicXML duration to Lilylet Duration
|
|
179
|
+
*
|
|
180
|
+
* @param divisions - Current divisions value (divisions per quarter note)
|
|
181
|
+
* @param duration - Duration value in divisions
|
|
182
|
+
* @param type - Note type (quarter, eighth, etc.)
|
|
183
|
+
* @param dots - Number of dots
|
|
184
|
+
* @param timeModification - Tuplet info
|
|
185
|
+
*/
|
|
186
|
+
export const convertDuration = (divisions, duration, type, dots = 0, timeModification) => {
|
|
187
|
+
let division;
|
|
188
|
+
if (type && TYPE_TO_DIVISION[type]) {
|
|
189
|
+
division = TYPE_TO_DIVISION[type];
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// Calculate from duration and divisions
|
|
193
|
+
// duration / divisions = quarter notes
|
|
194
|
+
// division = 4 / quarter_notes
|
|
195
|
+
const quarterNotes = duration / divisions;
|
|
196
|
+
division = 4 / quarterNotes;
|
|
197
|
+
// Round to nearest valid division
|
|
198
|
+
const validDivisions = [0.5, 1, 2, 4, 8, 16, 32, 64, 128];
|
|
199
|
+
division = validDivisions.reduce((prev, curr) => Math.abs(curr - division) < Math.abs(prev - division) ? curr : prev);
|
|
200
|
+
}
|
|
201
|
+
const result = {
|
|
202
|
+
division,
|
|
203
|
+
dots,
|
|
204
|
+
};
|
|
205
|
+
if (timeModification) {
|
|
206
|
+
// Store as Lilylet ratio: normalNotes/actualNotes
|
|
207
|
+
// MusicXML actual=3, normal=2 (triplet) → Lilylet ratio {num:2, den:3}
|
|
208
|
+
result.tuplet = {
|
|
209
|
+
numerator: timeModification.normalNotes,
|
|
210
|
+
denominator: timeModification.actualNotes,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
};
|
|
215
|
+
// ============ Key Signature Conversion ============
|
|
216
|
+
// Fifths to key signature mapping (major mode)
|
|
217
|
+
const FIFTHS_TO_KEY_MAJOR = {
|
|
218
|
+
[-7]: { pitch: Phonet.c, accidental: Accidental.flat },
|
|
219
|
+
[-6]: { pitch: Phonet.g, accidental: Accidental.flat },
|
|
220
|
+
[-5]: { pitch: Phonet.d, accidental: Accidental.flat },
|
|
221
|
+
[-4]: { pitch: Phonet.a, accidental: Accidental.flat },
|
|
222
|
+
[-3]: { pitch: Phonet.e, accidental: Accidental.flat },
|
|
223
|
+
[-2]: { pitch: Phonet.b, accidental: Accidental.flat },
|
|
224
|
+
[-1]: { pitch: Phonet.f },
|
|
225
|
+
[0]: { pitch: Phonet.c },
|
|
226
|
+
[1]: { pitch: Phonet.g },
|
|
227
|
+
[2]: { pitch: Phonet.d },
|
|
228
|
+
[3]: { pitch: Phonet.a },
|
|
229
|
+
[4]: { pitch: Phonet.e },
|
|
230
|
+
[5]: { pitch: Phonet.b },
|
|
231
|
+
[6]: { pitch: Phonet.f, accidental: Accidental.sharp },
|
|
232
|
+
[7]: { pitch: Phonet.c, accidental: Accidental.sharp },
|
|
233
|
+
};
|
|
234
|
+
// Fifths to key signature mapping (minor mode)
|
|
235
|
+
const FIFTHS_TO_KEY_MINOR = {
|
|
236
|
+
[-7]: { pitch: Phonet.a, accidental: Accidental.flat },
|
|
237
|
+
[-6]: { pitch: Phonet.e, accidental: Accidental.flat },
|
|
238
|
+
[-5]: { pitch: Phonet.b, accidental: Accidental.flat },
|
|
239
|
+
[-4]: { pitch: Phonet.f },
|
|
240
|
+
[-3]: { pitch: Phonet.c },
|
|
241
|
+
[-2]: { pitch: Phonet.g },
|
|
242
|
+
[-1]: { pitch: Phonet.d },
|
|
243
|
+
[0]: { pitch: Phonet.a },
|
|
244
|
+
[1]: { pitch: Phonet.e },
|
|
245
|
+
[2]: { pitch: Phonet.b },
|
|
246
|
+
[3]: { pitch: Phonet.f, accidental: Accidental.sharp },
|
|
247
|
+
[4]: { pitch: Phonet.c, accidental: Accidental.sharp },
|
|
248
|
+
[5]: { pitch: Phonet.g, accidental: Accidental.sharp },
|
|
249
|
+
[6]: { pitch: Phonet.d, accidental: Accidental.sharp },
|
|
250
|
+
[7]: { pitch: Phonet.a, accidental: Accidental.sharp },
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Convert MusicXML key (fifths, mode) to KeySignature
|
|
254
|
+
*/
|
|
255
|
+
export const convertKeySignature = (fifths, mode) => {
|
|
256
|
+
const isMinor = mode?.toLowerCase() === 'minor';
|
|
257
|
+
const mapping = isMinor
|
|
258
|
+
? FIFTHS_TO_KEY_MINOR[fifths]
|
|
259
|
+
: FIFTHS_TO_KEY_MAJOR[fifths];
|
|
260
|
+
if (!mapping) {
|
|
261
|
+
console.warn(`Unknown key signature: fifths=${fifths}, mode=${mode}`);
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
pitch: mapping.pitch,
|
|
266
|
+
accidental: mapping.accidental,
|
|
267
|
+
mode: isMinor ? 'minor' : 'major',
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
// ============ Clef Conversion ============
|
|
271
|
+
/**
|
|
272
|
+
* Convert MusicXML clef (sign, line) to Lilylet Clef
|
|
273
|
+
*/
|
|
274
|
+
export const convertClef = (sign, line) => {
|
|
275
|
+
const upperSign = sign.toUpperCase();
|
|
276
|
+
if (upperSign === 'G') {
|
|
277
|
+
return Clef.treble;
|
|
278
|
+
}
|
|
279
|
+
else if (upperSign === 'F') {
|
|
280
|
+
return Clef.bass;
|
|
281
|
+
}
|
|
282
|
+
else if (upperSign === 'C') {
|
|
283
|
+
// C clef - alto clef on line 3, tenor on line 4
|
|
284
|
+
return Clef.alto;
|
|
285
|
+
}
|
|
286
|
+
console.warn(`Unknown clef: sign=${sign}, line=${line}`);
|
|
287
|
+
return undefined;
|
|
288
|
+
};
|
|
289
|
+
// ============ Stem Direction Conversion ============
|
|
290
|
+
export const convertStemDirection = (stem) => {
|
|
291
|
+
switch (stem.toLowerCase()) {
|
|
292
|
+
case 'up':
|
|
293
|
+
return StemDirection.up;
|
|
294
|
+
case 'down':
|
|
295
|
+
return StemDirection.down;
|
|
296
|
+
default:
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
// ============ Articulation Conversion ============
|
|
301
|
+
const ARTICULATION_MAP = {
|
|
302
|
+
staccato: ArticulationType.staccato,
|
|
303
|
+
staccatissimo: ArticulationType.staccatissimo,
|
|
304
|
+
tenuto: ArticulationType.tenuto,
|
|
305
|
+
accent: ArticulationType.accent,
|
|
306
|
+
'strong-accent': ArticulationType.marcato,
|
|
307
|
+
detachedLegato: ArticulationType.portato,
|
|
308
|
+
'detached-legato': ArticulationType.portato,
|
|
309
|
+
};
|
|
310
|
+
export const convertArticulation = (name) => {
|
|
311
|
+
return ARTICULATION_MAP[name];
|
|
312
|
+
};
|
|
313
|
+
// ============ Ornament Conversion ============
|
|
314
|
+
const ORNAMENT_MAP = {
|
|
315
|
+
trill: OrnamentType.trill,
|
|
316
|
+
'trill-mark': OrnamentType.trill,
|
|
317
|
+
turn: OrnamentType.turn,
|
|
318
|
+
'inverted-turn': OrnamentType.turn,
|
|
319
|
+
mordent: OrnamentType.mordent,
|
|
320
|
+
'inverted-mordent': OrnamentType.prall,
|
|
321
|
+
};
|
|
322
|
+
export const convertOrnament = (name) => {
|
|
323
|
+
return ORNAMENT_MAP[name];
|
|
324
|
+
};
|
|
325
|
+
// ============ Dynamic Conversion ============
|
|
326
|
+
const DYNAMIC_MAP = {
|
|
327
|
+
ppp: DynamicType.ppp,
|
|
328
|
+
pp: DynamicType.pp,
|
|
329
|
+
p: DynamicType.p,
|
|
330
|
+
mp: DynamicType.mp,
|
|
331
|
+
mf: DynamicType.mf,
|
|
332
|
+
f: DynamicType.f,
|
|
333
|
+
ff: DynamicType.ff,
|
|
334
|
+
fff: DynamicType.fff,
|
|
335
|
+
sfz: DynamicType.sfz,
|
|
336
|
+
sf: DynamicType.sfz,
|
|
337
|
+
rfz: DynamicType.rfz,
|
|
338
|
+
rf: DynamicType.rfz,
|
|
339
|
+
fz: DynamicType.sfz,
|
|
340
|
+
sfp: DynamicType.sfz,
|
|
341
|
+
sfpp: DynamicType.sfz,
|
|
342
|
+
fp: DynamicType.f, // forte-piano, approximate
|
|
343
|
+
};
|
|
344
|
+
export const convertDynamic = (name) => {
|
|
345
|
+
return DYNAMIC_MAP[name.toLowerCase()];
|
|
346
|
+
};
|
|
347
|
+
// ============ Hairpin Conversion ============
|
|
348
|
+
export const convertWedge = (type, isStart) => {
|
|
349
|
+
if (type === 'crescendo') {
|
|
350
|
+
return isStart ? HairpinType.crescendoStart : HairpinType.crescendoEnd;
|
|
351
|
+
}
|
|
352
|
+
else if (type === 'diminuendo') {
|
|
353
|
+
return isStart ? HairpinType.diminuendoStart : HairpinType.diminuendoEnd;
|
|
354
|
+
}
|
|
355
|
+
else if (type === 'stop') {
|
|
356
|
+
// For stop, we need context to know if it's crescendo or diminuendo end
|
|
357
|
+
// Default to crescendo end
|
|
358
|
+
return HairpinType.crescendoEnd;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
};
|
|
362
|
+
// ============ Pedal Conversion ============
|
|
363
|
+
export const convertPedal = (type) => {
|
|
364
|
+
switch (type.toLowerCase()) {
|
|
365
|
+
case 'start':
|
|
366
|
+
return PedalType.sustainOn;
|
|
367
|
+
case 'stop':
|
|
368
|
+
return PedalType.sustainOff;
|
|
369
|
+
case 'change':
|
|
370
|
+
// Pedal change = off then on (we'll emit sustainOff)
|
|
371
|
+
return PedalType.sustainOff;
|
|
372
|
+
default:
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
// ============ Barline Conversion ============
|
|
377
|
+
const BARLINE_STYLE_MAP = {
|
|
378
|
+
regular: '|',
|
|
379
|
+
'light-light': '||',
|
|
380
|
+
'light-heavy': '|.',
|
|
381
|
+
'heavy-light': '.|',
|
|
382
|
+
'heavy-heavy': '||',
|
|
383
|
+
dashed: ':',
|
|
384
|
+
dotted: ';',
|
|
385
|
+
none: '',
|
|
386
|
+
};
|
|
387
|
+
export const convertBarlineStyle = (barStyle, repeatDirection) => {
|
|
388
|
+
if (repeatDirection === 'backward') {
|
|
389
|
+
return ':|.';
|
|
390
|
+
}
|
|
391
|
+
if (repeatDirection === 'forward') {
|
|
392
|
+
return '.|:';
|
|
393
|
+
}
|
|
394
|
+
if (barStyle) {
|
|
395
|
+
return BARLINE_STYLE_MAP[barStyle] || '|';
|
|
396
|
+
}
|
|
397
|
+
return '|';
|
|
398
|
+
};
|
|
399
|
+
// ============ Harmony/Chord Symbol Conversion ============
|
|
400
|
+
const KIND_MAP = {
|
|
401
|
+
major: '',
|
|
402
|
+
minor: 'm',
|
|
403
|
+
augmented: 'aug',
|
|
404
|
+
diminished: 'dim',
|
|
405
|
+
dominant: '7',
|
|
406
|
+
'major-seventh': 'maj7',
|
|
407
|
+
'minor-seventh': 'm7',
|
|
408
|
+
'diminished-seventh': 'dim7',
|
|
409
|
+
'augmented-seventh': 'aug7',
|
|
410
|
+
'half-diminished': 'm7b5',
|
|
411
|
+
'major-minor': 'mMaj7',
|
|
412
|
+
'major-sixth': '6',
|
|
413
|
+
'minor-sixth': 'm6',
|
|
414
|
+
'dominant-ninth': '9',
|
|
415
|
+
'major-ninth': 'maj9',
|
|
416
|
+
'minor-ninth': 'm9',
|
|
417
|
+
suspended: 'sus',
|
|
418
|
+
'suspended-second': 'sus2',
|
|
419
|
+
'suspended-fourth': 'sus4',
|
|
420
|
+
power: '5',
|
|
421
|
+
none: '',
|
|
422
|
+
};
|
|
423
|
+
const STEP_NAMES = {
|
|
424
|
+
C: 'C',
|
|
425
|
+
D: 'D',
|
|
426
|
+
E: 'E',
|
|
427
|
+
F: 'F',
|
|
428
|
+
G: 'G',
|
|
429
|
+
A: 'A',
|
|
430
|
+
B: 'B',
|
|
431
|
+
};
|
|
432
|
+
const ALTER_SYMBOLS = {
|
|
433
|
+
[-2]: 'bb',
|
|
434
|
+
[-1]: 'b',
|
|
435
|
+
[0]: '',
|
|
436
|
+
[1]: '#',
|
|
437
|
+
[2]: '##',
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* Convert MusicXML harmony to chord symbol text
|
|
441
|
+
*/
|
|
442
|
+
export const convertHarmonyToText = (rootStep, rootAlter, kind, bassStep, bassAlter) => {
|
|
443
|
+
let result = STEP_NAMES[rootStep.toUpperCase()] || rootStep;
|
|
444
|
+
if (rootAlter) {
|
|
445
|
+
result += ALTER_SYMBOLS[rootAlter] || '';
|
|
446
|
+
}
|
|
447
|
+
const kindSuffix = KIND_MAP[kind];
|
|
448
|
+
if (kindSuffix !== undefined) {
|
|
449
|
+
result += kindSuffix;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// Unknown kind, just append as-is
|
|
453
|
+
result += kind;
|
|
454
|
+
}
|
|
455
|
+
// Add bass note if present (slash chord)
|
|
456
|
+
if (bassStep) {
|
|
457
|
+
result += '/';
|
|
458
|
+
result += STEP_NAMES[bassStep.toUpperCase()] || bassStep;
|
|
459
|
+
if (bassAlter) {
|
|
460
|
+
result += ALTER_SYMBOLS[bassAlter] || '';
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return result;
|
|
464
|
+
};
|
|
465
|
+
// ============ Time Signature Helpers ============
|
|
466
|
+
export const createFraction = (numerator, denominator) => ({
|
|
467
|
+
numerator,
|
|
468
|
+
denominator,
|
|
469
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { LilyletDoc } from "./types";
|
|
2
|
+
export interface ParseWarning {
|
|
3
|
+
type: 'partial-mismatch';
|
|
4
|
+
message: string;
|
|
5
|
+
declared: number;
|
|
6
|
+
actual: number;
|
|
7
|
+
}
|
|
8
|
+
declare const parseCode: (code: string) => LilyletDoc;
|
|
9
|
+
/**
|
|
10
|
+
* Return warnings emitted during the most recent parseCode() call.
|
|
11
|
+
* Currently reports \partial duration mismatches.
|
|
12
|
+
*/
|
|
13
|
+
declare const getParseWarnings: () => ParseWarning[];
|
|
14
|
+
export { parseCode, getParseWarnings, };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-ignore - jison generated file
|
|
2
|
+
import { parser, parse as grammarParse } from "./grammar.jison.js";
|
|
3
|
+
const PHONETS = "cdefgab";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve relative pitch to absolute octave.
|
|
6
|
+
*
|
|
7
|
+
* In LilyPond relative mode:
|
|
8
|
+
* - The base pitch starts at middle C (step=0, octave=0)
|
|
9
|
+
* - For each note, the interval is calculated from the previous pitch
|
|
10
|
+
* - If |interval| > 3 (more than a 4th), the octave is adjusted to find nearest pitch
|
|
11
|
+
* - Explicit ' and , markers add/subtract octaves from this calculated position
|
|
12
|
+
*
|
|
13
|
+
* Example: c to g = interval +4, so we go DOWN a 4th (octave -1) instead of up a 5th
|
|
14
|
+
* c to f = interval +3, so we go UP a 4th (same octave)
|
|
15
|
+
*/
|
|
16
|
+
const resolveRelativePitch = (env, pitch) => {
|
|
17
|
+
const step = PHONETS.indexOf(pitch.phonet);
|
|
18
|
+
if (step === -1) {
|
|
19
|
+
throw new Error(`Invalid phonet: "${pitch.phonet}". Expected one of: c, d, e, f, g, a, b`);
|
|
20
|
+
}
|
|
21
|
+
const interval = step - env.step;
|
|
22
|
+
// Calculate octave adjustment based on interval
|
|
23
|
+
// If interval > 3, go down instead of up (e.g., c to g is down a 4th)
|
|
24
|
+
// If interval < -3, go up instead of down (e.g., g to c is up a 4th)
|
|
25
|
+
const octInc = Math.floor(Math.abs(interval) / 4) * -Math.sign(interval);
|
|
26
|
+
// Update environment and pitch
|
|
27
|
+
// pitch.octave contains the explicit ' and , markers from parsing
|
|
28
|
+
env.octave += pitch.octave + octInc;
|
|
29
|
+
env.step = step;
|
|
30
|
+
// Store absolute octave back in pitch
|
|
31
|
+
pitch.octave = env.octave;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Process events in a voice to resolve relative pitches.
|
|
35
|
+
* Pitch reset events (from newlines) reset the pitch base to middle C.
|
|
36
|
+
*/
|
|
37
|
+
const resolveVoicePitches = (events, env) => {
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
if (event.type === 'pitchReset') {
|
|
40
|
+
// Reset pitch base to middle C on newline
|
|
41
|
+
env.step = 0;
|
|
42
|
+
env.octave = 0;
|
|
43
|
+
}
|
|
44
|
+
else if (event.type === 'note') {
|
|
45
|
+
const noteEvent = event;
|
|
46
|
+
const pitches = noteEvent.pitches;
|
|
47
|
+
if (pitches.length > 0) {
|
|
48
|
+
// First pitch is relative to previous note/chord's first pitch
|
|
49
|
+
resolveRelativePitch(env, pitches[0]);
|
|
50
|
+
// For chord: subsequent pitches are relative to each other
|
|
51
|
+
if (pitches.length > 1) {
|
|
52
|
+
const chordEnv = { step: env.step, octave: env.octave };
|
|
53
|
+
for (let i = 1; i < pitches.length; i++) {
|
|
54
|
+
resolveRelativePitch(chordEnv, pitches[i]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (event.type === 'rest') {
|
|
60
|
+
// Rest with pitch (e.g., a''\rest) should update the pitch environment
|
|
61
|
+
const restEvent = event;
|
|
62
|
+
if (restEvent.pitch) {
|
|
63
|
+
resolveRelativePitch(env, restEvent.pitch);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (event.type === 'tuplet') {
|
|
67
|
+
// Process tuplet events
|
|
68
|
+
for (const tupletEvent of event.events) {
|
|
69
|
+
if (tupletEvent.type === 'note') {
|
|
70
|
+
const pitches = tupletEvent.pitches;
|
|
71
|
+
if (pitches.length > 0) {
|
|
72
|
+
resolveRelativePitch(env, pitches[0]);
|
|
73
|
+
if (pitches.length > 1) {
|
|
74
|
+
const chordEnv = { step: env.step, octave: env.octave };
|
|
75
|
+
for (let i = 1; i < pitches.length; i++) {
|
|
76
|
+
resolveRelativePitch(chordEnv, pitches[i]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (tupletEvent.type === 'rest') {
|
|
82
|
+
const restEvent = tupletEvent;
|
|
83
|
+
if (restEvent.pitch) {
|
|
84
|
+
resolveRelativePitch(env, restEvent.pitch);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (event.type === 'tremolo') {
|
|
90
|
+
// Process tremolo pitches
|
|
91
|
+
if (event.pitchA.length > 0) {
|
|
92
|
+
resolveRelativePitch(env, event.pitchA[0]);
|
|
93
|
+
if (event.pitchA.length > 1) {
|
|
94
|
+
const chordEnv = { step: env.step, octave: env.octave };
|
|
95
|
+
for (let i = 1; i < event.pitchA.length; i++) {
|
|
96
|
+
resolveRelativePitch(chordEnv, event.pitchA[i]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (event.pitchB.length > 0) {
|
|
101
|
+
resolveRelativePitch(env, event.pitchB[0]);
|
|
102
|
+
if (event.pitchB.length > 1) {
|
|
103
|
+
const chordEnv = { step: env.step, octave: env.octave };
|
|
104
|
+
for (let i = 1; i < event.pitchB.length; i++) {
|
|
105
|
+
resolveRelativePitch(chordEnv, event.pitchB[i]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Process all pitches in a document to resolve relative pitch mode.
|
|
114
|
+
*
|
|
115
|
+
* Structure: measure > part > voice
|
|
116
|
+
* - Pitch environment is continuous across measures unless a pitchReset event is encountered
|
|
117
|
+
* - pitchReset events are generated from newlines in the source code
|
|
118
|
+
* - Each part/voice combination maintains its own pitch environment
|
|
119
|
+
*/
|
|
120
|
+
const resolveDocumentPitches = (doc) => {
|
|
121
|
+
// Track pitch environment per (part index, voice index) across all measures
|
|
122
|
+
// Key format: "partIndex-voiceIndex"
|
|
123
|
+
const envMap = {};
|
|
124
|
+
for (const measure of doc.measures) {
|
|
125
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
126
|
+
const part = measure.parts[pi];
|
|
127
|
+
for (let vi = 0; vi < part.voices.length; vi++) {
|
|
128
|
+
const voice = part.voices[vi];
|
|
129
|
+
const key = `${pi}-${vi}`;
|
|
130
|
+
// Get or create env for this part/voice combination
|
|
131
|
+
if (!envMap[key]) {
|
|
132
|
+
envMap[key] = { step: 0, octave: 0 };
|
|
133
|
+
}
|
|
134
|
+
// Process voice events with the persistent env
|
|
135
|
+
// pitchReset events within will reset the env to middle C
|
|
136
|
+
resolveVoicePitches(voice.events, envMap[key]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const parseCode = (code) => {
|
|
142
|
+
// Reset parser state before each parse to avoid contamination
|
|
143
|
+
if (parser && parser.resetState) {
|
|
144
|
+
parser.resetState();
|
|
145
|
+
}
|
|
146
|
+
const raw = grammarParse(code);
|
|
147
|
+
// Resolve relative pitch mode
|
|
148
|
+
resolveDocumentPitches(raw);
|
|
149
|
+
return raw;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Return warnings emitted during the most recent parseCode() call.
|
|
153
|
+
* Currently reports \partial duration mismatches.
|
|
154
|
+
*/
|
|
155
|
+
const getParseWarnings = () => {
|
|
156
|
+
if (parser && parser.getWarnings) {
|
|
157
|
+
return parser.getWarnings();
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
};
|
|
161
|
+
export { parseCode, getParseWarnings, };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lilylet Document Serializer
|
|
3
|
+
*
|
|
4
|
+
* Converts LilyletDoc to Lilylet (.lyl) string format.
|
|
5
|
+
* Uses relative pitch mode matching the parser's behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { LilyletDoc } from "./types";
|
|
8
|
+
/**
|
|
9
|
+
* Serialize a LilyletDoc to Lilylet (.lyl) string format
|
|
10
|
+
*/
|
|
11
|
+
export declare const serializeLilyletDoc: (doc: LilyletDoc) => string;
|