@k-l-lambda/lilylet 0.1.48 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/abc/abc.d.ts +102 -0
- package/lib/abc/abc.js +25 -0
- package/lib/abc/grammar.jison.js +1203 -0
- package/lib/abc/parser.d.ts +3 -0
- package/lib/abc/parser.js +6 -0
- package/lib/abcDecoder.d.ts +1 -0
- package/lib/abcDecoder.js +1 -0
- package/lib/grammar.jison.js +1 -1303
- package/lib/index.d.ts +1 -8
- package/lib/index.js +1 -10
- package/lib/lilylet/abcDecoder.d.ts +25 -0
- package/lib/lilylet/abcDecoder.js +1007 -0
- package/lib/lilylet/grammar.jison.js +1308 -0
- package/lib/lilylet/index.d.ts +10 -0
- package/lib/lilylet/index.js +10 -0
- package/lib/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/lilylet/lilypondDecoder.js +1053 -0
- package/lib/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/lilylet/lilypondEncoder.js +759 -0
- package/lib/lilylet/meiEncoder.d.ts +8 -0
- package/lib/lilylet/meiEncoder.js +1808 -0
- package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/lilylet/musicXmlEncoder.js +701 -0
- package/lib/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/lilylet/musicXmlTypes.js +7 -0
- package/lib/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/lilylet/musicXmlUtils.js +469 -0
- package/lib/lilylet/parser.d.ts +3 -0
- package/lib/lilylet/parser.js +151 -0
- package/lib/lilylet/serializer.d.ts +11 -0
- package/lib/lilylet/serializer.js +653 -0
- package/lib/lilylet/types.d.ts +245 -0
- package/lib/lilylet/types.js +99 -0
- package/lib/lilypondDecoder.d.ts +1 -29
- package/lib/lilypondDecoder.js +1 -1006
- package/lib/lilypondEncoder.d.ts +1 -34
- package/lib/lilypondEncoder.js +1 -759
- package/lib/meiEncoder.d.ts +1 -8
- package/lib/meiEncoder.js +1 -1545
- package/lib/musicXmlDecoder.d.ts +1 -20
- package/lib/musicXmlDecoder.js +1 -1151
- package/lib/musicXmlEncoder.d.ts +1 -15
- package/lib/musicXmlEncoder.js +1 -666
- package/lib/musicXmlTypes.d.ts +1 -199
- package/lib/musicXmlTypes.js +1 -7
- package/lib/musicXmlUtils.d.ts +1 -81
- package/lib/musicXmlUtils.js +1 -435
- package/lib/parser.d.ts +1 -3
- package/lib/parser.js +1 -151
- package/lib/serializer.d.ts +1 -11
- package/lib/serializer.js +1 -650
- package/lib/types.d.ts +1 -244
- package/lib/types.js +1 -99
- package/package.json +2 -1
- package/source/abc/abc.jison +692 -0
- package/source/abc/abc.ts +176 -0
- package/source/abc/grammar.jison.js +1203 -0
- package/source/abc/parser.ts +12 -0
- package/source/lilylet/abcDecoder.ts +1121 -0
- package/source/lilylet/grammar.jison.js +170 -165
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/lilypondDecoder.ts +92 -42
- package/source/lilylet/meiEncoder.ts +280 -0
- package/source/lilylet/musicXmlDecoder.ts +74 -27
- package/source/lilylet/musicXmlEncoder.ts +201 -146
- package/source/lilylet/musicXmlUtils.ts +46 -4
- package/source/lilylet/serializer.ts +3 -0
- package/source/lilylet/types.ts +1 -0
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
convertBarlineStyle,
|
|
67
67
|
convertHarmonyToText,
|
|
68
68
|
createFraction,
|
|
69
|
+
TYPE_TO_DIVISION,
|
|
69
70
|
} from './musicXmlUtils';
|
|
70
71
|
|
|
71
72
|
// ============ Spanner Tracker ============
|
|
@@ -163,12 +164,9 @@ class TupletTracker {
|
|
|
163
164
|
// Add to all active tuplets (in case of nested tuplets)
|
|
164
165
|
for (const [, tuplet] of this.activeTuplets) {
|
|
165
166
|
// Set ratio from first event's duration.tuplet
|
|
167
|
+
// convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
|
|
166
168
|
if (!tuplet.ratio && event.duration.tuplet) {
|
|
167
|
-
|
|
168
|
-
tuplet.ratio = {
|
|
169
|
-
numerator: event.duration.tuplet.denominator,
|
|
170
|
-
denominator: event.duration.tuplet.numerator,
|
|
171
|
-
};
|
|
169
|
+
tuplet.ratio = { ...event.duration.tuplet };
|
|
172
170
|
}
|
|
173
171
|
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
174
172
|
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
@@ -237,6 +235,7 @@ class VoiceTracker {
|
|
|
237
235
|
private currentPosition: number = 0;
|
|
238
236
|
private divisions: number = 1;
|
|
239
237
|
private staves: number = 1;
|
|
238
|
+
private currentStaff: Map<number, number> = new Map();
|
|
240
239
|
|
|
241
240
|
setDivisions(div: number): void {
|
|
242
241
|
this.divisions = div;
|
|
@@ -260,6 +259,7 @@ class VoiceTracker {
|
|
|
260
259
|
events: [],
|
|
261
260
|
staff,
|
|
262
261
|
});
|
|
262
|
+
this.currentStaff.set(voiceNum, staff);
|
|
263
263
|
}
|
|
264
264
|
const voice = this.voices.get(voiceNum)!;
|
|
265
265
|
// Update staff if specified
|
|
@@ -271,6 +271,11 @@ class VoiceTracker {
|
|
|
271
271
|
|
|
272
272
|
addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
|
|
273
273
|
const voice = this.getOrCreateVoice(voiceNum, staff);
|
|
274
|
+
const prevStaff = this.currentStaff.get(voiceNum) || 1;
|
|
275
|
+
if (staff > 0 && staff !== prevStaff) {
|
|
276
|
+
voice.events.push({ type: 'context', staff } as ContextChange);
|
|
277
|
+
this.currentStaff.set(voiceNum, staff);
|
|
278
|
+
}
|
|
274
279
|
voice.events.push(event);
|
|
275
280
|
voice.lastEvent = event;
|
|
276
281
|
this.currentPosition += duration;
|
|
@@ -306,6 +311,7 @@ class VoiceTracker {
|
|
|
306
311
|
reset(): void {
|
|
307
312
|
this.voices.clear();
|
|
308
313
|
this.currentPosition = 0;
|
|
314
|
+
this.currentStaff.clear();
|
|
309
315
|
}
|
|
310
316
|
}
|
|
311
317
|
|
|
@@ -825,20 +831,6 @@ const notationsToMarks = (
|
|
|
825
831
|
return marks;
|
|
826
832
|
};
|
|
827
833
|
|
|
828
|
-
// MusicXML beat-unit to division mapping
|
|
829
|
-
const BEAT_UNIT_TO_DIVISION: Record<string, number> = {
|
|
830
|
-
'maxima': 0.125,
|
|
831
|
-
'long': 0.25,
|
|
832
|
-
'breve': 0.5,
|
|
833
|
-
'whole': 1,
|
|
834
|
-
'half': 2,
|
|
835
|
-
'quarter': 4,
|
|
836
|
-
'eighth': 8,
|
|
837
|
-
'16th': 16,
|
|
838
|
-
'32nd': 32,
|
|
839
|
-
'64th': 64,
|
|
840
|
-
};
|
|
841
|
-
|
|
842
834
|
// Common tempo words that should be converted to \tempo
|
|
843
835
|
const TEMPO_WORDS = new Set([
|
|
844
836
|
// Very slow
|
|
@@ -878,7 +870,7 @@ const directionToContextChange = (
|
|
|
878
870
|
// Metronome → Tempo (may combine with words)
|
|
879
871
|
if (direction.metronome) {
|
|
880
872
|
const { beatUnit, beatUnitDot, perMinute } = direction.metronome;
|
|
881
|
-
const division =
|
|
873
|
+
const division = TYPE_TO_DIVISION[beatUnit] || 4;
|
|
882
874
|
|
|
883
875
|
// Check if there's accompanying tempo text
|
|
884
876
|
let tempoText: string | undefined;
|
|
@@ -1078,6 +1070,10 @@ const convertMeasure = (
|
|
|
1078
1070
|
const staffNum = note.staff || 1;
|
|
1079
1071
|
currentVoice = voiceNum;
|
|
1080
1072
|
|
|
1073
|
+
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
1074
|
+
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
1075
|
+
voiceTracker.getOrCreateVoice(voiceNum, staffNum);
|
|
1076
|
+
|
|
1081
1077
|
// Check for tuplet start BEFORE processing the note
|
|
1082
1078
|
const tupletNotation = note.notations?.tuplet;
|
|
1083
1079
|
if (tupletNotation?.type === 'start') {
|
|
@@ -1283,6 +1279,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1283
1279
|
let lastKey: KeySignature | undefined;
|
|
1284
1280
|
let lastTimeSig: Fraction | undefined;
|
|
1285
1281
|
let isFirstMeasure = true;
|
|
1282
|
+
let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
|
|
1286
1283
|
const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
|
|
1287
1284
|
|
|
1288
1285
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
@@ -1351,7 +1348,9 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1351
1348
|
|
|
1352
1349
|
// If no voices found, create an empty one
|
|
1353
1350
|
if (voices.length === 0) {
|
|
1354
|
-
voices.push({ staff:
|
|
1351
|
+
voices.push({ staff: lastVoiceStaff, events: [] });
|
|
1352
|
+
} else {
|
|
1353
|
+
lastVoiceStaff = voices[0].staff || 1;
|
|
1355
1354
|
}
|
|
1356
1355
|
|
|
1357
1356
|
const measure: Measure = {
|
|
@@ -1395,19 +1394,67 @@ export const decode = (xmlString: string): LilyletDoc => {
|
|
|
1395
1394
|
// Parse metadata
|
|
1396
1395
|
const metadata = parseMetadata(doc);
|
|
1397
1396
|
|
|
1397
|
+
// Parse <part-list> to get part names
|
|
1398
|
+
const partNames: Map<string, string> = new Map();
|
|
1399
|
+
const partListEl = doc.getElementsByTagName('part-list')[0];
|
|
1400
|
+
if (partListEl) {
|
|
1401
|
+
const scorePartEls = getElements(partListEl, 'score-part');
|
|
1402
|
+
for (const sp of scorePartEls) {
|
|
1403
|
+
const id = getAttribute(sp, 'id');
|
|
1404
|
+
const name = getElementText(sp, 'part-name');
|
|
1405
|
+
if (id && name) {
|
|
1406
|
+
partNames.set(id, name);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1398
1411
|
// Get parts
|
|
1399
|
-
const partEls =
|
|
1412
|
+
const partEls = getDirectChildren(root, 'part');
|
|
1400
1413
|
if (partEls.length === 0) {
|
|
1401
1414
|
throw new Error('No parts found in MusicXML');
|
|
1402
1415
|
}
|
|
1403
1416
|
|
|
1404
|
-
//
|
|
1405
|
-
|
|
1406
|
-
const
|
|
1407
|
-
|
|
1417
|
+
// Convert all parts
|
|
1418
|
+
const allPartResults: { measures: Measure[]; name?: string; partId?: string }[] = [];
|
|
1419
|
+
for (const partEl of partEls) {
|
|
1420
|
+
const partId = getAttribute(partEl, 'id') || undefined;
|
|
1421
|
+
const { measures } = convertPart(partEl);
|
|
1422
|
+
const name = partId ? partNames.get(partId) : undefined;
|
|
1423
|
+
allPartResults.push({ measures, name, partId });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Merge parts: combine into multi-part measures
|
|
1427
|
+
const numMeasures = Math.max(...allPartResults.map(p => p.measures.length));
|
|
1428
|
+
const mergedMeasures: Measure[] = [];
|
|
1429
|
+
|
|
1430
|
+
for (let mi = 0; mi < numMeasures; mi++) {
|
|
1431
|
+
const parts: Part[] = [];
|
|
1432
|
+
|
|
1433
|
+
for (const partResult of allPartResults) {
|
|
1434
|
+
const sourceMeasure = partResult.measures[mi];
|
|
1435
|
+
if (sourceMeasure && sourceMeasure.parts.length > 0) {
|
|
1436
|
+
const part = sourceMeasure.parts[0];
|
|
1437
|
+
if (partResult.name) {
|
|
1438
|
+
part.name = partResult.name;
|
|
1439
|
+
}
|
|
1440
|
+
parts.push(part);
|
|
1441
|
+
} else {
|
|
1442
|
+
// Empty part placeholder
|
|
1443
|
+
parts.push({ voices: [{ staff: 1, events: [] }] });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Use key/timeSig from the first part's measure (they should be consistent)
|
|
1448
|
+
const firstPartMeasure = allPartResults[0].measures[mi];
|
|
1449
|
+
const measure: Measure = { parts };
|
|
1450
|
+
if (firstPartMeasure?.key) measure.key = firstPartMeasure.key;
|
|
1451
|
+
if (firstPartMeasure?.timeSig) measure.timeSig = firstPartMeasure.timeSig;
|
|
1452
|
+
|
|
1453
|
+
mergedMeasures.push(measure);
|
|
1454
|
+
}
|
|
1408
1455
|
|
|
1409
1456
|
const result: LilyletDoc = {
|
|
1410
|
-
measures,
|
|
1457
|
+
measures: mergedMeasures,
|
|
1411
1458
|
};
|
|
1412
1459
|
|
|
1413
1460
|
if (Object.keys(metadata).length > 0) {
|
|
@@ -37,11 +37,14 @@ import {
|
|
|
37
37
|
Fraction,
|
|
38
38
|
} from "./types";
|
|
39
39
|
|
|
40
|
+
import {
|
|
41
|
+
DIVISIONS,
|
|
42
|
+
DIVISION_TO_TYPE,
|
|
43
|
+
calculateDuration,
|
|
44
|
+
} from "./musicXmlUtils";
|
|
40
45
|
|
|
41
|
-
// === Constants and Reverse Mappings ===
|
|
42
46
|
|
|
43
|
-
//
|
|
44
|
-
const DIVISIONS = 4;
|
|
47
|
+
// === Constants and Reverse Mappings ===
|
|
45
48
|
|
|
46
49
|
// Phonet to MusicXML step
|
|
47
50
|
const PHONET_TO_STEP: Record<string, string> = {
|
|
@@ -63,19 +66,6 @@ const ACCIDENTAL_TO_ALTER: Record<string, number> = {
|
|
|
63
66
|
natural: 0,
|
|
64
67
|
};
|
|
65
68
|
|
|
66
|
-
// Division to MusicXML note type
|
|
67
|
-
const DIVISION_TO_TYPE: Record<number, string> = {
|
|
68
|
-
0.5: 'breve',
|
|
69
|
-
1: 'whole',
|
|
70
|
-
2: 'half',
|
|
71
|
-
4: 'quarter',
|
|
72
|
-
8: 'eighth',
|
|
73
|
-
16: '16th',
|
|
74
|
-
32: '32nd',
|
|
75
|
-
64: '64th',
|
|
76
|
-
128: '128th',
|
|
77
|
-
};
|
|
78
|
-
|
|
79
69
|
// Key signature to fifths (major keys)
|
|
80
70
|
const KEY_TO_FIFTHS: Record<string, number> = {
|
|
81
71
|
'c': 0,
|
|
@@ -161,34 +151,6 @@ const indent = (level: number): string => ' '.repeat(level);
|
|
|
161
151
|
|
|
162
152
|
// === Encoding Functions ===
|
|
163
153
|
|
|
164
|
-
/**
|
|
165
|
-
* Calculate duration in MusicXML divisions
|
|
166
|
-
*/
|
|
167
|
-
const calculateDuration = (duration: Duration): number => {
|
|
168
|
-
// Base duration: DIVISIONS * (4 / division)
|
|
169
|
-
// e.g., quarter (4) = DIVISIONS * 1 = 4
|
|
170
|
-
// half (2) = DIVISIONS * 2 = 8
|
|
171
|
-
// eighth (8) = DIVISIONS * 0.5 = 2
|
|
172
|
-
let dur = DIVISIONS * (4 / duration.division);
|
|
173
|
-
|
|
174
|
-
// Apply dots
|
|
175
|
-
if (duration.dots) {
|
|
176
|
-
let dotValue = dur / 2;
|
|
177
|
-
for (let i = 0; i < duration.dots; i++) {
|
|
178
|
-
dur += dotValue;
|
|
179
|
-
dotValue /= 2;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Apply tuplet ratio
|
|
184
|
-
if (duration.tuplet) {
|
|
185
|
-
dur = dur * duration.tuplet.denominator / duration.tuplet.numerator;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return Math.round(dur);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
|
|
192
154
|
/**
|
|
193
155
|
* Encode pitch to MusicXML
|
|
194
156
|
*/
|
|
@@ -224,9 +186,12 @@ const encodeDuration = (duration: Duration, level: number): string => {
|
|
|
224
186
|
}
|
|
225
187
|
|
|
226
188
|
if (duration.tuplet) {
|
|
189
|
+
// MusicXML: actual-notes = notes played (Lilylet denominator)
|
|
190
|
+
// normal-notes = normal count (Lilylet numerator)
|
|
191
|
+
// e.g., \times 2/3 → actual=3, normal=2
|
|
227
192
|
xml += `${indent(level)}<time-modification>\n`;
|
|
228
|
-
xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.
|
|
229
|
-
xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.
|
|
193
|
+
xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.denominator}</actual-notes>\n`;
|
|
194
|
+
xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.numerator}</normal-notes>\n`;
|
|
230
195
|
xml += `${indent(level)}</time-modification>\n`;
|
|
231
196
|
}
|
|
232
197
|
|
|
@@ -349,6 +314,10 @@ const encodeNotations = (marks: Mark[], level: number): string => {
|
|
|
349
314
|
otherNotations.push(`<slur type="${mark.start ? 'start' : 'stop'}" number="1"/>`);
|
|
350
315
|
break;
|
|
351
316
|
|
|
317
|
+
case 'tuplet' as any:
|
|
318
|
+
otherNotations.push(`<tuplet type="${(mark as any).start ? 'start' : 'stop'}"/>`);
|
|
319
|
+
break;
|
|
320
|
+
|
|
352
321
|
case 'fingering':
|
|
353
322
|
// Fingering goes in technical
|
|
354
323
|
break;
|
|
@@ -494,6 +463,49 @@ const encodeRest = (
|
|
|
494
463
|
};
|
|
495
464
|
|
|
496
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Encode a rest event with tuplet notation start/stop
|
|
468
|
+
*/
|
|
469
|
+
const encodeRestWithTuplet = (
|
|
470
|
+
event: RestEvent,
|
|
471
|
+
voice: number,
|
|
472
|
+
staff: number,
|
|
473
|
+
level: number,
|
|
474
|
+
isFirst: boolean,
|
|
475
|
+
isLast: boolean
|
|
476
|
+
): string => {
|
|
477
|
+
let xml = `${indent(level)}<note>\n`;
|
|
478
|
+
|
|
479
|
+
xml += `${indent(level + 1)}<rest`;
|
|
480
|
+
if (event.fullMeasure) {
|
|
481
|
+
xml += ' measure="yes"';
|
|
482
|
+
}
|
|
483
|
+
xml += '/>\n';
|
|
484
|
+
|
|
485
|
+
xml += encodeDuration(event.duration, level + 1);
|
|
486
|
+
|
|
487
|
+
xml += `${indent(level + 1)}<voice>${voice}</voice>\n`;
|
|
488
|
+
|
|
489
|
+
if (staff > 0) {
|
|
490
|
+
xml += `${indent(level + 1)}<staff>${staff}</staff>\n`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Add tuplet notations
|
|
494
|
+
xml += `${indent(level + 1)}<notations>\n`;
|
|
495
|
+
if (isFirst) {
|
|
496
|
+
xml += `${indent(level + 2)}<tuplet type="start"/>\n`;
|
|
497
|
+
}
|
|
498
|
+
if (isLast) {
|
|
499
|
+
xml += `${indent(level + 2)}<tuplet type="stop"/>\n`;
|
|
500
|
+
}
|
|
501
|
+
xml += `${indent(level + 1)}</notations>\n`;
|
|
502
|
+
|
|
503
|
+
xml += `${indent(level)}</note>\n`;
|
|
504
|
+
|
|
505
|
+
return xml;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
|
|
497
509
|
/**
|
|
498
510
|
* Encode direction element (dynamics, tempo, etc.)
|
|
499
511
|
*/
|
|
@@ -627,10 +639,11 @@ const encodeHarmony = (event: HarmonyEvent, level: number): string => {
|
|
|
627
639
|
|
|
628
640
|
|
|
629
641
|
/**
|
|
630
|
-
* Encode a complete measure
|
|
642
|
+
* Encode a complete measure for a single part
|
|
631
643
|
*/
|
|
632
644
|
const encodeMeasure = (
|
|
633
645
|
measure: Measure,
|
|
646
|
+
partIndex: number,
|
|
634
647
|
measureNumber: number,
|
|
635
648
|
isFirst: boolean,
|
|
636
649
|
prevKey: KeySignature | undefined,
|
|
@@ -639,32 +652,33 @@ const encodeMeasure = (
|
|
|
639
652
|
): string => {
|
|
640
653
|
let xml = `${indent(level)}<measure number="${measureNumber}">\n`;
|
|
641
654
|
|
|
655
|
+
const part = measure.parts[partIndex];
|
|
656
|
+
if (!part) {
|
|
657
|
+
xml += `${indent(level)}</measure>\n`;
|
|
658
|
+
return xml;
|
|
659
|
+
}
|
|
660
|
+
|
|
642
661
|
// Determine if we need attributes
|
|
643
662
|
const needAttributes = isFirst ||
|
|
644
663
|
(measure.key && JSON.stringify(measure.key) !== JSON.stringify(prevKey)) ||
|
|
645
664
|
(measure.timeSig && JSON.stringify(measure.timeSig) !== JSON.stringify(prevTime));
|
|
646
665
|
|
|
647
|
-
// Find max staff number
|
|
666
|
+
// Find max staff number within this part
|
|
648
667
|
let maxStaff = 1;
|
|
649
|
-
for (const
|
|
650
|
-
|
|
651
|
-
maxStaff = Math.max(maxStaff, voice.staff || 1);
|
|
652
|
-
}
|
|
668
|
+
for (const voice of part.voices) {
|
|
669
|
+
maxStaff = Math.max(maxStaff, voice.staff || 1);
|
|
653
670
|
}
|
|
654
671
|
|
|
655
672
|
// Encode attributes if needed
|
|
656
673
|
if (needAttributes) {
|
|
657
|
-
// Find clef from first voice
|
|
674
|
+
// Find clef from first voice of this part
|
|
658
675
|
let clef: Clef | undefined;
|
|
659
|
-
for (const
|
|
660
|
-
for (const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
676
|
+
for (const voice of part.voices) {
|
|
677
|
+
for (const event of voice.events) {
|
|
678
|
+
if (event.type === 'context' && event.clef) {
|
|
679
|
+
clef = event.clef;
|
|
680
|
+
break;
|
|
666
681
|
}
|
|
667
|
-
if (clef) break;
|
|
668
682
|
}
|
|
669
683
|
if (clef) break;
|
|
670
684
|
}
|
|
@@ -678,95 +692,124 @@ const encodeMeasure = (
|
|
|
678
692
|
});
|
|
679
693
|
}
|
|
680
694
|
|
|
681
|
-
// Encode voices
|
|
695
|
+
// Encode voices (voice numbering starts at 1 for each part)
|
|
682
696
|
let voiceNum = 1;
|
|
683
697
|
let currentPosition = 0;
|
|
684
698
|
|
|
685
|
-
for (const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
let voicePosition = 0;
|
|
689
|
-
|
|
690
|
-
// Backup if needed
|
|
691
|
-
if (currentPosition > 0 && voiceNum > 1) {
|
|
692
|
-
xml += `${indent(level + 1)}<backup>\n`;
|
|
693
|
-
xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
|
|
694
|
-
xml += `${indent(level + 1)}</backup>\n`;
|
|
695
|
-
voicePosition = 0;
|
|
696
|
-
}
|
|
699
|
+
for (const voice of part.voices) {
|
|
700
|
+
let currentStaff = voice.staff || 1;
|
|
701
|
+
let voicePosition = 0;
|
|
697
702
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
if (directionMarks.length > 0) {
|
|
706
|
-
xml += encodeDirection(directionMarks, level + 1);
|
|
707
|
-
}
|
|
703
|
+
// Backup if needed
|
|
704
|
+
if (currentPosition > 0 && voiceNum > 1) {
|
|
705
|
+
xml += `${indent(level + 1)}<backup>\n`;
|
|
706
|
+
xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
|
|
707
|
+
xml += `${indent(level + 1)}</backup>\n`;
|
|
708
|
+
voicePosition = 0;
|
|
709
|
+
}
|
|
708
710
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
pitches: [event.pitches[i]],
|
|
719
|
-
};
|
|
720
|
-
xml += encodeNote(chordEvent, voiceNum, staff, level + 1, true);
|
|
721
|
-
}
|
|
722
|
-
break;
|
|
711
|
+
for (const event of voice.events) {
|
|
712
|
+
switch (event.type) {
|
|
713
|
+
case 'note': {
|
|
714
|
+
// Check for direction marks (dynamics, hairpins, pedals)
|
|
715
|
+
const directionMarks = event.marks?.filter(m =>
|
|
716
|
+
m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
|
|
717
|
+
) || [];
|
|
718
|
+
if (directionMarks.length > 0) {
|
|
719
|
+
xml += encodeDirection(directionMarks, level + 1);
|
|
723
720
|
}
|
|
724
721
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
722
|
+
// Encode main note
|
|
723
|
+
xml += encodeNote(event, voiceNum, currentStaff, level + 1);
|
|
724
|
+
const dur = calculateDuration(event.duration);
|
|
725
|
+
voicePosition += dur;
|
|
726
|
+
|
|
727
|
+
// Encode chord notes
|
|
728
|
+
for (let i = 1; i < event.pitches.length; i++) {
|
|
729
|
+
const chordEvent: NoteEvent = {
|
|
730
|
+
...event,
|
|
731
|
+
pitches: [event.pitches[i]],
|
|
732
|
+
};
|
|
733
|
+
xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
|
|
730
734
|
}
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
731
737
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
+
case 'rest': {
|
|
739
|
+
xml += encodeRest(event, voiceNum, currentStaff, level + 1);
|
|
740
|
+
const dur = calculateDuration(event.duration);
|
|
741
|
+
voicePosition += dur;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
case 'context': {
|
|
746
|
+
if (event.tempo) {
|
|
747
|
+
xml += encodeTempo(event.tempo, level + 1);
|
|
748
|
+
}
|
|
749
|
+
if (event.staff) {
|
|
750
|
+
currentStaff = event.staff;
|
|
738
751
|
}
|
|
752
|
+
// Other context changes are handled in attributes
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
739
755
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
756
|
+
case 'tuplet': {
|
|
757
|
+
const tupletEvents = event.events;
|
|
758
|
+
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
759
|
+
const subEvent = tupletEvents[ti];
|
|
760
|
+
// Set tuplet ratio on duration so encodeDuration emits <time-modification>
|
|
761
|
+
const originalTuplet = subEvent.duration.tuplet;
|
|
762
|
+
subEvent.duration.tuplet = event.ratio;
|
|
763
|
+
|
|
764
|
+
const isFirst = ti === 0;
|
|
765
|
+
const isLast = ti === tupletEvents.length - 1;
|
|
766
|
+
|
|
767
|
+
if (subEvent.type === 'note') {
|
|
768
|
+
// Add tuplet notation marks
|
|
769
|
+
const tupletMarks: Mark[] = [];
|
|
770
|
+
if (isFirst) tupletMarks.push({ markType: 'tuplet', start: true } as any);
|
|
771
|
+
if (isLast) tupletMarks.push({ markType: 'tuplet', start: false } as any);
|
|
772
|
+
|
|
773
|
+
if (tupletMarks.length > 0) {
|
|
774
|
+
const origMarks = subEvent.marks;
|
|
775
|
+
subEvent.marks = [...(subEvent.marks || []), ...tupletMarks];
|
|
776
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
|
|
777
|
+
subEvent.marks = origMarks;
|
|
778
|
+
} else {
|
|
779
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
|
|
750
780
|
}
|
|
781
|
+
const dur = calculateDuration(subEvent.duration);
|
|
782
|
+
voicePosition += dur;
|
|
783
|
+
} else if (subEvent.type === 'rest') {
|
|
784
|
+
if (isFirst || isLast) {
|
|
785
|
+
xml += encodeRestWithTuplet(subEvent, voiceNum, currentStaff, level + 1, isFirst, isLast);
|
|
786
|
+
} else {
|
|
787
|
+
xml += encodeRest(subEvent, voiceNum, currentStaff, level + 1);
|
|
788
|
+
}
|
|
789
|
+
const dur = calculateDuration(subEvent.duration);
|
|
790
|
+
voicePosition += dur;
|
|
751
791
|
}
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
792
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
break;
|
|
793
|
+
// Restore original tuplet value
|
|
794
|
+
subEvent.duration.tuplet = originalTuplet;
|
|
758
795
|
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
759
798
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
799
|
+
case 'barline': {
|
|
800
|
+
xml += encodeBarline(event, level + 1);
|
|
801
|
+
break;
|
|
764
802
|
}
|
|
765
|
-
}
|
|
766
803
|
|
|
767
|
-
|
|
768
|
-
|
|
804
|
+
case 'harmony': {
|
|
805
|
+
xml += encodeHarmony(event, level + 1);
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
769
809
|
}
|
|
810
|
+
|
|
811
|
+
currentPosition = Math.max(currentPosition, voicePosition);
|
|
812
|
+
voiceNum++;
|
|
770
813
|
}
|
|
771
814
|
|
|
772
815
|
xml += `${indent(level)}</measure>\n`;
|
|
@@ -823,30 +866,42 @@ export const encode = (doc: LilyletDoc): string => {
|
|
|
823
866
|
xml += encodeMetadata(doc.metadata, 1);
|
|
824
867
|
}
|
|
825
868
|
|
|
826
|
-
//
|
|
869
|
+
// Determine number of parts from first measure
|
|
870
|
+
const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
|
|
871
|
+
|
|
872
|
+
// Part list
|
|
827
873
|
xml += `${indent(1)}<part-list>\n`;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
874
|
+
for (let pi = 0; pi < numParts; pi++) {
|
|
875
|
+
const partId = `P${pi + 1}`;
|
|
876
|
+
const partName = doc.measures[0]?.parts[pi]?.name
|
|
877
|
+
|| (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
|
|
878
|
+
xml += `${indent(2)}<score-part id="${partId}">\n`;
|
|
879
|
+
xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
|
|
880
|
+
xml += `${indent(2)}</score-part>\n`;
|
|
881
|
+
}
|
|
831
882
|
xml += `${indent(1)}</part-list>\n`;
|
|
832
883
|
|
|
833
|
-
//
|
|
834
|
-
|
|
884
|
+
// Encode each part
|
|
885
|
+
for (let pi = 0; pi < numParts; pi++) {
|
|
886
|
+
const partId = `P${pi + 1}`;
|
|
887
|
+
xml += `${indent(1)}<part id="${partId}">\n`;
|
|
835
888
|
|
|
836
|
-
|
|
837
|
-
|
|
889
|
+
let prevKey: KeySignature | undefined;
|
|
890
|
+
let prevTime: Fraction | undefined;
|
|
838
891
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
892
|
+
for (let i = 0; i < doc.measures.length; i++) {
|
|
893
|
+
const measure = doc.measures[i];
|
|
894
|
+
const isFirst = i === 0;
|
|
842
895
|
|
|
843
|
-
|
|
896
|
+
xml += encodeMeasure(measure, pi, i + 1, isFirst, prevKey, prevTime, 2);
|
|
897
|
+
|
|
898
|
+
if (measure.key) prevKey = measure.key;
|
|
899
|
+
if (measure.timeSig) prevTime = measure.timeSig;
|
|
900
|
+
}
|
|
844
901
|
|
|
845
|
-
|
|
846
|
-
if (measure.timeSig) prevTime = measure.timeSig;
|
|
902
|
+
xml += `${indent(1)}</part>\n`;
|
|
847
903
|
}
|
|
848
904
|
|
|
849
|
-
xml += `${indent(1)}</part>\n`;
|
|
850
905
|
xml += '</score-partwise>\n';
|
|
851
906
|
|
|
852
907
|
return xml;
|