@k-l-lambda/lilylet 0.1.30

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